gummyarena/script.js

1115 lines
34 KiB
JavaScript
Raw Normal View History

2025-03-15 06:49:30 +00:00
/* ===========================
GLOBAL & DEFAULT VARIABLES
=========================== */
let snakeIdCounter = 0;
let maxLengths = {};
let elapsedTime = 0;
let canvasWidth, canvasHeight;
let lastFrameTime = null;
/* Updated defaults */
const DEFAULT_BRAIN_SIZE = 15;
const DEFAULT_SNAKE_SPEED = 3.5;
const DEFAULT_MAX_FOOD = 120;
const DEFAULT_SPAWN_CHANCE = 3;
const DEFAULT_FOOD_POINTS = 3;
const DEFAULT_DROPPED_FOOD_POINTS = 10;
const DEFAULT_SURVIVAL_BONUS = 2;
const DEFAULT_BODY_LENGTH_INCREMENT = 10;
const DEFAULT_THICKNESS_INCREMENT = 0.05;
const DEFAULT_SNAKE_COUNT = 30;
const DEFAULT_LENGTH_BONUS = 1;
const DEFAULT_KILL_BONUS = 0;
/* Evolution & Environment Defaults */
const DEFAULT_MUTATION_RATE = 0.1;
const DEFAULT_CROSSOVER_BIAS = 0.5;
const DEFAULT_FOOD_DECAY_TIME = 2000;
const DEFAULT_RESPAWN_DELAY = 0;
/* Newly added default constants for Turn Rate & Boost Feature */
const DEFAULT_TURN_RATE = 0.15;
const DEFAULT_BOOST_COST = 1.0;
const DEFAULT_BOOST_MULTIPLIER = 2.0;
/* Live controls */
let CONTROL_BRAIN_SIZE = DEFAULT_BRAIN_SIZE;
let CONTROL_SNAKE_SPEED = DEFAULT_SNAKE_SPEED;
let CONTROL_MAX_FOOD = DEFAULT_MAX_FOOD;
let CONTROL_SPAWN_CHANCE = DEFAULT_SPAWN_CHANCE / 100;
let CONTROL_FOOD_POINTS = DEFAULT_FOOD_POINTS;
let CONTROL_DROPPED_FOOD_POINTS = DEFAULT_DROPPED_FOOD_POINTS;
let CONTROL_SURVIVAL_BONUS = DEFAULT_SURVIVAL_BONUS / 1000;
let CONTROL_BODY_LENGTH_INCREMENT = DEFAULT_BODY_LENGTH_INCREMENT;
let CONTROL_THICKNESS_INCREMENT = DEFAULT_THICKNESS_INCREMENT;
let CONTROL_SNAKE_COUNT = DEFAULT_SNAKE_COUNT;
let CONTROL_LENGTH_BONUS = DEFAULT_LENGTH_BONUS;
let CONTROL_KILL_BONUS = DEFAULT_KILL_BONUS;
let CONTROL_MUTATION_RATE = DEFAULT_MUTATION_RATE;
let CONTROL_CROSSOVER_BIAS = DEFAULT_CROSSOVER_BIAS;
let CONTROL_FOOD_DECAY_TIME = DEFAULT_FOOD_DECAY_TIME;
let CONTROL_RESPAWN_DELAY = DEFAULT_RESPAWN_DELAY;
/* Newly added live control variables for Turn Rate & Boost */
let CONTROL_TURN_RATE = DEFAULT_TURN_RATE;
let CONTROL_BOOST_COST = DEFAULT_BOOST_COST;
let CONTROL_BOOST_MULTIPLIER = DEFAULT_BOOST_MULTIPLIER;
/* Random name generator arrays */
const CREATURE_WORDS = [
"Shadow", "Arcane", "Giant", "Flying", "Poison", "Cosmic", "Metal",
"Lurking", "Frost", "Ghoulish", "Infernal", "Serpent", "Draconic", "Wraith",
"Neon", "Psychedelic", "Electro", "Turbo", "Nova", "Cyborg", "Spooky", "Radiant",
"Celestial", "Demonic", "Slimy", "Furry", "Scaled", "Venomous", "Quantum", "Crystal",
"Armored", "Vampire", "Bio", "Cyber", "Warped", "Chaotic", "Magnetic", "Sonic",
"Pulsing", "Dark", "Psycho", "Laser", "Molten", "Mystic", "Ghostly", "Titanic",
"Ancient", "Cursed",
// Playful/fun names
"Bouncy", "Zany", "Wacky", "Funky", "Quirky", "Silly", "Peppy", "Jolly"
];
const OBJECT_WORDS = [
"Hydra", "Sphinx", "Slime", "Golem", "Dragon", "Beast", "Worm",
"Wraith", "Zombie", "Ogre", "Witch", "Basilisk", "Wyvern", "Spider",
"Phantom", "Fiend", "Demon", "Alien", "Parasite", "Robot", "Ghoul", "Tentacle",
"Construct", "Crawler", "Serpent", "Locust", "Lizard", "Poltergeist", "Chimera", "Bug",
"Scorpion", "Leech", "Wisp", "Mothman", "Revenant", "Medusa", "Dragonfly", "Amoeba",
"Pixie", "Doppelganger", "Harpy", "Gargoyle", "Troglodyte", "Valkyrie", "Kelpie",
"Manticore", "Cockatrice", "Homunculus",
// Playful/fun names
"Cupcake", "Noodle", "Banana", "Pickle", "Gizmo", "Biscuit", "Muffin", "Sparky"
];
function getRandomSnakeName(id) {
const cWord = CREATURE_WORDS[Math.floor(Math.random() * CREATURE_WORDS.length)];
const oWord = OBJECT_WORDS[Math.floor(Math.random() * OBJECT_WORDS.length)];
return `${cWord}${oWord}#${id}`;
}
/* ===========================
Canvas Setup
=========================== */
const canvas = document.getElementById('simCanvas');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvasWidth = canvas.width;
canvasHeight = canvas.height;
}
resizeCanvas();
window.addEventListener('resize', () => {
resizeCanvas();
updateLayout();
});
/* Utility */
function distance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
function angleDifference(a, b) {
let diff = a - b;
while (diff < -Math.PI) diff += 2 * Math.PI;
while (diff > Math.PI) diff -= 2 * Math.PI;
return diff;
}
function getRandomNeonColor() {
const hue = Math.floor(Math.random() * 360);
return `hsl(${hue}, 100%, 50%)`;
}
/* ===========================
RESPONSIVE LAYOUT LOGIC
=========================== */
let isMobileLayout = null;
function updateLayout() {
const width = window.innerWidth;
2025-03-15 17:43:55 +00:00
const newIsMobile = (width < 1000);
2025-03-15 06:49:30 +00:00
if (newIsMobile === isMobileLayout) return;
isMobileLayout = newIsMobile;
const tlPanel = document.getElementById('timeAndLeaderboardPanel');
const ctrlPanel = document.getElementById('controlPanel');
if (isMobileLayout) {
// fix top & bottom for mobile
tlPanel.style.position = 'fixed';
tlPanel.style.top = '20px';
tlPanel.style.left = '50%';
tlPanel.style.transform = 'translateX(-50%)';
ctrlPanel.style.position = 'fixed';
ctrlPanel.style.bottom = '20px';
ctrlPanel.style.left = '50%';
ctrlPanel.style.transform = 'translateX(-50%)';
removeDraggable(tlPanel);
removeDraggable(ctrlPanel);
// auto-minimize control panel, expand top panel
const tlContent = tlPanel.querySelector('.content');
const tlMinimizeBtn = tlPanel.querySelector('.minimize');
if (tlContent && tlMinimizeBtn) {
tlContent.style.display = 'block';
tlMinimizeBtn.textContent = '';
}
const ctrlContent = ctrlPanel.querySelector('.content');
const ctrlMinimizeBtn = ctrlPanel.querySelector('.minimize');
if (ctrlContent && ctrlMinimizeBtn) {
ctrlContent.style.display = 'none';
ctrlMinimizeBtn.textContent = '+';
}
} else {
// desktop layout
tlPanel.style.position = 'absolute';
tlPanel.style.top = '20px';
tlPanel.style.left = '50%';
tlPanel.style.transform = 'translateX(-50%)';
ctrlPanel.style.position = 'absolute';
ctrlPanel.style.bottom = '20px';
ctrlPanel.style.left = '50%';
ctrlPanel.style.transform = 'translateX(-50%)';
removeDraggable(tlPanel);
removeDraggable(ctrlPanel);
}
}
function removeDraggable(panel) {
if (!panel) return;
const header = panel.querySelector('.header');
if (!header) return;
const clone = panel.cloneNode(true);
panel.parentNode.replaceChild(clone, panel);
attachMinimizeLogic(clone);
attachTopLevelTabLogic(clone);
attachRewardSubTabLogic(clone);
wireUIEvents();
}
function attachMinimizeLogic(panel) {
const miniBtn = panel.querySelector('.minimize');
if (!miniBtn) return;
miniBtn.addEventListener('click', () => {
const content = panel.querySelector('.content');
if (!content) return;
const isClosed = (content.style.display === 'none');
if (isClosed) {
content.style.display = 'block';
miniBtn.textContent = '';
if (isMobileLayout) {
// auto-minimize the other panel if mobile
if (panel.id === 'controlPanel') {
const otherPanel = document.getElementById('timeAndLeaderboardPanel');
const otherContent = otherPanel.querySelector('.content');
if (otherContent && otherContent.style.display !== 'none') {
otherContent.style.display = 'none';
otherPanel.querySelector('.minimize').textContent = '+';
}
} else {
const otherPanel = document.getElementById('controlPanel');
const otherContent = otherPanel.querySelector('.content');
if (otherContent && otherContent.style.display !== 'none') {
otherContent.style.display = 'none';
otherPanel.querySelector('.minimize').textContent = '+';
}
}
}
} else {
content.style.display = 'none';
miniBtn.textContent = '+';
}
});
}
/**
* Top-level tab logic (Reward, Food, etc.)
*/
function attachTopLevelTabLogic(panel) {
const topLevelBtns = panel.querySelectorAll('.top-level-tabs .tabBtn');
if (!topLevelBtns.length) return;
topLevelBtns.forEach(btn => {
btn.addEventListener('click', () => {
const parent = btn.closest('.top-level-tabs');
const panelContent = parent.parentNode;
const allTabBtns = parent.querySelectorAll('.tabBtn');
const allTabs = panelContent.querySelectorAll('.tab-content');
allTabBtns.forEach(b => b.classList.remove('active'));
allTabs.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
const tabId = btn.getAttribute('data-tab');
const target = panelContent.querySelector('#' + tabId);
if (target) target.classList.add('active');
// if "rewardTab," default sub-tab = "Points & Kills"
if (tabId === 'rewardTab') {
const rewardSubTabs = target.querySelectorAll('.reward-sub-content');
const subBtns = target.querySelectorAll('.reward-subtabs .tabBtn');
rewardSubTabs.forEach(s => s.classList.remove('active'));
subBtns.forEach(s => s.classList.remove('active'));
const firstSubBtn = target.querySelector('.reward-subtabs .tabBtn[data-subtab="rewardPointsSubTab"]');
const firstSubContent = target.querySelector('#rewardPointsSubTab');
if (firstSubBtn) firstSubBtn.classList.add('active');
if (firstSubContent) firstSubContent.classList.add('active');
}
});
});
}
/**
* Sub-tab logic for the "Reward" panel
*/
function attachRewardSubTabLogic(panel) {
const rewardTab = panel.querySelector('#rewardTab');
if (!rewardTab) return;
const subTabButtons = rewardTab.querySelectorAll('.reward-subtabs .tabBtn');
subTabButtons.forEach(subBtn => {
subBtn.addEventListener('click', () => {
subTabButtons.forEach(b => b.classList.remove('active'));
const allSubContents = rewardTab.querySelectorAll('.reward-sub-content');
allSubContents.forEach(s => s.classList.remove('active'));
subBtn.classList.add('active');
const subId = subBtn.getAttribute('data-subtab');
const targetSub = rewardTab.querySelector('#' + subId);
if (targetSub) targetSub.classList.add('active');
});
});
}
/* Wire up UI events (sliders, etc.) */
function wireUIEvents() {
// EXACTLY as before, hooking each slider to the right variable and label
const killBonusSlider = document.getElementById('killBonusSlider');
if (killBonusSlider) {
killBonusSlider.oninput = e => {
CONTROL_KILL_BONUS = parseFloat(e.target.value);
updateControlValue('killBonusVal', e.target.value);
};
}
const snakeSpeedSlider = document.getElementById('snakeSpeedSlider');
if (snakeSpeedSlider) {
snakeSpeedSlider.oninput = e => {
CONTROL_SNAKE_SPEED = parseFloat(e.target.value);
updateControlValue('snakeSpeedVal', e.target.value);
};
}
const brainSizeSlider = document.getElementById('brainSizeSlider');
if (brainSizeSlider) {
brainSizeSlider.oninput = e => {
const newVal = parseFloat(e.target.value);
if (newVal > CONTROL_BRAIN_SIZE) CONTROL_BRAIN_SIZE = newVal;
else e.target.value = CONTROL_BRAIN_SIZE;
updateControlValue('brainSizeVal', e.target.value);
};
}
const survivalBonusSlider = document.getElementById('survivalBonusSlider');
if (survivalBonusSlider) {
survivalBonusSlider.oninput = e => {
CONTROL_SURVIVAL_BONUS = parseFloat(e.target.value) * 0.001;
updateControlValue('survivalBonusVal', e.target.value);
};
}
const bodyLengthIncSlider = document.getElementById('bodyLengthIncSlider');
if (bodyLengthIncSlider) {
bodyLengthIncSlider.oninput = e => {
CONTROL_BODY_LENGTH_INCREMENT = parseFloat(e.target.value);
updateControlValue('bodyLengthIncVal', e.target.value);
};
}
const thicknessIncSlider = document.getElementById('thicknessIncSlider');
if (thicknessIncSlider) {
thicknessIncSlider.oninput = e => {
CONTROL_THICKNESS_INCREMENT = parseFloat(e.target.value);
updateControlValue('thicknessIncVal', e.target.value);
};
}
const maxFoodSlider = document.getElementById('maxFoodSlider');
if (maxFoodSlider) {
maxFoodSlider.oninput = e => {
CONTROL_MAX_FOOD = parseInt(e.target.value);
updateControlValue('maxFoodVal', e.target.value);
};
}
const spawnChanceSlider = document.getElementById('spawnChanceSlider');
if (spawnChanceSlider) {
spawnChanceSlider.oninput = e => {
CONTROL_SPAWN_CHANCE = parseFloat(e.target.value) / 100;
updateControlValue('spawnChanceVal', e.target.value);
};
}
const foodPointsSlider = document.getElementById('foodPointsSlider');
if (foodPointsSlider) {
foodPointsSlider.oninput = e => {
CONTROL_FOOD_POINTS = parseFloat(e.target.value);
updateControlValue('foodPointsVal', e.target.value);
};
}
const droppedFoodPointsSlider = document.getElementById('droppedFoodPointsSlider');
if (droppedFoodPointsSlider) {
droppedFoodPointsSlider.oninput = e => {
CONTROL_DROPPED_FOOD_POINTS = parseFloat(e.target.value);
updateControlValue('droppedFoodPointsVal', e.target.value);
};
}
const snakeCountSlider = document.getElementById('snakeCountSlider');
if (snakeCountSlider) {
snakeCountSlider.oninput = e => {
const val = parseInt(e.target.value);
updateControlValue('snakeCountVal', val);
adjustSnakeCount(val);
};
}
const lengthBonusSlider = document.getElementById('lengthBonusSlider');
if (lengthBonusSlider) {
lengthBonusSlider.oninput = e => {
CONTROL_LENGTH_BONUS = parseFloat(e.target.value);
updateControlValue('lengthBonusVal', e.target.value);
};
}
const timeAccelSlider = document.getElementById('timeAccelSlider');
if (timeAccelSlider) {
timeAccelSlider.oninput = e => {
updateControlValue('timeAccelVal', e.target.value);
};
}
const mutationRateSlider = document.getElementById('mutationRateSlider');
if (mutationRateSlider) {
mutationRateSlider.oninput = e => {
CONTROL_MUTATION_RATE = parseFloat(e.target.value);
updateControlValue('mutationRateVal', e.target.value);
};
}
const crossoverBiasSlider = document.getElementById('crossoverBiasSlider');
if (crossoverBiasSlider) {
crossoverBiasSlider.oninput = e => {
CONTROL_CROSSOVER_BIAS = parseFloat(e.target.value);
updateControlValue('crossoverBiasVal', e.target.value);
};
}
const foodDecayTimeSlider = document.getElementById('foodDecayTimeSlider');
if (foodDecayTimeSlider) {
foodDecayTimeSlider.oninput = e => {
CONTROL_FOOD_DECAY_TIME = parseInt(e.target.value);
updateControlValue('foodDecayTimeVal', e.target.value);
};
}
const respawnDelaySlider = document.getElementById('respawnDelaySlider');
if (respawnDelaySlider) {
respawnDelaySlider.oninput = e => {
CONTROL_RESPAWN_DELAY = parseFloat(e.target.value);
updateControlValue('respawnDelayVal', e.target.value);
};
}
// Turn Rate
const turnRateSlider = document.getElementById('turnRateSlider');
if (turnRateSlider) {
turnRateSlider.oninput = e => {
CONTROL_TURN_RATE = parseFloat(e.target.value);
updateControlValue('turnRateVal', e.target.value);
};
}
// Boost Cost
const boostCostSlider = document.getElementById('boostCostSlider');
if (boostCostSlider) {
boostCostSlider.oninput = e => {
CONTROL_BOOST_COST = parseFloat(e.target.value);
updateControlValue('boostCostVal', e.target.value);
};
}
// Boost Multiplier
const boostMultiplierSlider = document.getElementById('boostMultiplierSlider');
if (boostMultiplierSlider) {
boostMultiplierSlider.oninput = e => {
CONTROL_BOOST_MULTIPLIER = parseFloat(e.target.value);
updateControlValue('boostMultiplierVal', e.target.value);
};
}
// Buttons
const restoreBtn = document.getElementById('restoreDefaultsButton');
if (restoreBtn) restoreBtn.onclick = restoreDefaults;
const resetBtn = document.getElementById('resetSimButton');
if (resetBtn) resetBtn.onclick = resetSimulation;
const fastForwardBtn = document.getElementById('fastForwardButton');
if (fastForwardBtn) fastForwardBtn.onclick = fastForwardSimulation;
}
/* Adjust snake population */
function adjustSnakeCount(desiredCount) {
if (!sim) return;
const currentCount = sim.snakes.length;
if (desiredCount < currentCount) {
const diff = currentCount - desiredCount;
// sort by fitness, remove the lowest
sim.snakes.sort((a, b) => a.fitness - b.fitness);
for (let i = 0; i < diff; i++) {
sim.removeSnakeNoRespawn(0, true);
}
} else if (desiredCount > currentCount) {
const diff = desiredCount - currentCount;
for (let i = 0; i < diff; i++) {
sim.spawnNewSnake();
}
}
}
/* CONTROL FUNCTIONS */
function updateControlValue(labelId, val) {
const el = document.getElementById(labelId);
if (el) el.textContent = val;
}
function restoreDefaults() {
CONTROL_BRAIN_SIZE = 15;
CONTROL_SNAKE_SPEED = 3.5;
CONTROL_MAX_FOOD = 120;
CONTROL_SPAWN_CHANCE = 0.03;
CONTROL_FOOD_POINTS = 3;
CONTROL_DROPPED_FOOD_POINTS = 10;
CONTROL_SURVIVAL_BONUS = 0.002;
CONTROL_BODY_LENGTH_INCREMENT = 10;
CONTROL_THICKNESS_INCREMENT = 0.05;
CONTROL_SNAKE_COUNT = 30;
CONTROL_LENGTH_BONUS = 1;
CONTROL_KILL_BONUS = 0;
CONTROL_MUTATION_RATE = 0.1;
CONTROL_CROSSOVER_BIAS = 0.5;
CONTROL_FOOD_DECAY_TIME = 2000;
CONTROL_RESPAWN_DELAY = 0;
CONTROL_TURN_RATE = DEFAULT_TURN_RATE;
CONTROL_BOOST_COST = DEFAULT_BOOST_COST;
CONTROL_BOOST_MULTIPLIER = DEFAULT_BOOST_MULTIPLIER;
// Update all slider positions:
document.getElementById('brainSizeSlider').value = 15;
document.getElementById('snakeSpeedSlider').value = 3.5;
document.getElementById('maxFoodSlider').value = 120;
document.getElementById('spawnChanceSlider').value = 3;
document.getElementById('foodPointsSlider').value = 3;
document.getElementById('droppedFoodPointsSlider').value = 10;
document.getElementById('survivalBonusSlider').value = 2;
document.getElementById('bodyLengthIncSlider').value = 10;
document.getElementById('thicknessIncSlider').value = 0.05;
document.getElementById('snakeCountSlider').value = 30;
document.getElementById('lengthBonusSlider').value = 1;
document.getElementById('killBonusSlider').value = 0;
document.getElementById('mutationRateSlider').value = 0.1;
document.getElementById('crossoverBiasSlider').value = 0.5;
document.getElementById('foodDecayTimeSlider').value = 2000;
document.getElementById('respawnDelaySlider').value = 0;
document.getElementById('timeAccelSlider').value = 0;
document.getElementById('turnRateSlider').value = DEFAULT_TURN_RATE;
document.getElementById('boostCostSlider').value = DEFAULT_BOOST_COST;
document.getElementById('boostMultiplierSlider').value = DEFAULT_BOOST_MULTIPLIER;
// Update all displayed labels:
updateControlValue('brainSizeVal', 15);
updateControlValue('snakeSpeedVal', 3.5);
updateControlValue('maxFoodVal', 120);
updateControlValue('spawnChanceVal', 3);
updateControlValue('foodPointsVal', 3);
updateControlValue('droppedFoodPointsVal', 10);
updateControlValue('survivalBonusVal', 2);
updateControlValue('bodyLengthIncVal', 10);
updateControlValue('thicknessIncVal', 0.05);
updateControlValue('snakeCountVal', 30);
updateControlValue('lengthBonusVal', 1);
updateControlValue('killBonusVal', 0);
updateControlValue('mutationRateVal', 0.1);
updateControlValue('crossoverBiasVal', 0.5);
updateControlValue('foodDecayTimeVal', 2000);
updateControlValue('respawnDelayVal', 0);
updateControlValue('timeAccelVal', 0);
updateControlValue('turnRateVal', DEFAULT_TURN_RATE);
updateControlValue('boostCostVal', DEFAULT_BOOST_COST);
updateControlValue('boostMultiplierVal', DEFAULT_BOOST_MULTIPLIER);
}
function resetSimulation() {
snakeIdCounter = 0;
maxLengths = {};
elapsedTime = 0;
sim = new Simulation();
}
function fastForwardSimulation() {
if (!sim) return;
const minutesToSkip = parseInt(document.getElementById('timeAccelSlider').value);
updateControlValue('timeAccelVal', minutesToSkip);
if (minutesToSkip > 0) {
showLoadingOverlay();
setTimeout(() => {
const framesToSim = minutesToSkip * 60; // e.g. 1 minute => 60 frames
for (let i = 0; i < framesToSim; i++) {
sim.update(1.0 / 60.0);
}
elapsedTime += minutesToSkip;
hideLoadingOverlay();
}, 50);
}
}
function showLoadingOverlay() {
document.getElementById('loadingOverlay').style.display = 'flex';
}
function hideLoadingOverlay() {
document.getElementById('loadingOverlay').style.display = 'none';
}
/* ===========================
CLASS DEFINITIONS
=========================== */
/**
* Brain with two outputs (turn, boost).
*/
class Brain {
constructor(size, existingConfig = null) {
this.size = size;
if (existingConfig) {
this.config = existingConfig.slice();
} else {
this.config = Array.from({ length: size }, () => Math.random() < 0.5);
}
this.fitness = 0;
}
decide(sensors) {
let sumForTurning = 0;
let sumForBoost = 0;
const half = Math.floor(this.config.length / 2);
// turning
for (let i = 0; i < sensors.length; i++) {
if (sensors[i]) sumForTurning++;
}
for (let i = 0; i < half; i++) {
if (this.config[i]) sumForTurning++;
}
sumForTurning += Math.random();
const turnThreshold = (sensors.length + half) / 2;
const turn = (sumForTurning > turnThreshold) ? 1 : -1;
// boost
for (let i = half; i < this.config.length; i++) {
if (this.config[i]) sumForBoost++;
}
sumForBoost += Math.random();
const boostThreshold = (this.config.length - half) / 2;
const boost = (sumForBoost > boostThreshold);
return { turn, boost };
}
mutate() {
if (Math.random() < CONTROL_MUTATION_RATE) {
const index = Math.floor(Math.random() * this.config.length);
this.config[index] = !this.config[index];
}
}
static crossover(b1, b2) {
const size = Math.max(b1.size, b2.size);
const newBrain = new Brain(size);
for (let i = 0; i < size; i++) {
const bit1 = b1.config[i % b1.config.length];
const bit2 = b2.config[i % b2.config.length];
newBrain.config[i] = (Math.random() < CONTROL_CROSSOVER_BIAS) ? bit1 : bit2;
}
return newBrain;
}
}
class Food {
constructor(x, y, isDropped = false, worth = CONTROL_FOOD_POINTS) {
this.x = x;
this.y = y;
this.radius = 3;
this.color = '#FFF';
this.isDropped = isDropped;
this.worth = worth;
}
update() {
return true;
}
render(ctx) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
}
class DroppedFood extends Food {
constructor(x, y) {
super(x, y, true, CONTROL_DROPPED_FOOD_POINTS);
this.creationTime = Date.now();
this.lifespan = CONTROL_FOOD_DECAY_TIME;
}
update() {
const age = Date.now() - this.creationTime;
return age < this.lifespan;
}
render(ctx) {
const age = Date.now() - this.creationTime;
let alpha = 1.0;
if (age < 1000) {
alpha = 0.5 + 0.5 * Math.sin(performance.now() / 80);
} else {
const fadeFrac = 1 - (age - 1000) / 1000;
alpha = fadeFrac;
}
ctx.save();
ctx.fillStyle = "#ff66ff";
ctx.shadowColor = "rgba(255,102,255,0.8)";
ctx.shadowBlur = 10;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
class Snake {
constructor(x, y, brain = null) {
this.id = snakeIdCounter++;
this.name = getRandomSnakeName(this.id);
this.x = x;
this.y = y;
this.thickness = 4;
this.radius = this.thickness * 0.6;
this.speed = CONTROL_SNAKE_SPEED;
this.direction = Math.random() * Math.PI * 2;
this.frontColor = getRandomNeonColor();
this.backColor = getRandomNeonColor();
this.brain = brain || new Brain(CONTROL_BRAIN_SIZE);
this.fitness = 0;
// We'll store objects: { x, y, edgeJump?: boolean }
this.body = [{ x: this.x, y: this.y }];
this.bodyLength = 50;
this.isBoosting = false;
}
gatherSensors(allFood, allSnakes) {
// Omitted for brevity
return [];
}
update(dt, allFood, allSnakes) {
// 1) Decide
const decision = this.brain.decide(this.gatherSensors(allFood, allSnakes));
const turn = decision.turn;
const wantBoost = decision.boost;
// 2) Turning
const sizeFactor = Math.max(1, this.bodyLength / 50);
const turnAngle = CONTROL_TURN_RATE / sizeFactor;
this.direction += turn * turnAngle;
// 3) Boost
if (wantBoost && this.bodyLength > 1) {
this.isBoosting = true;
} else {
this.isBoosting = false;
}
if (this.isBoosting) {
this.speed = CONTROL_SNAKE_SPEED * CONTROL_BOOST_MULTIPLIER;
const lengthLost = CONTROL_BOOST_COST * dt;
this.bodyLength -= lengthLost;
const thicknessLost = lengthLost * CONTROL_THICKNESS_INCREMENT;
this.thickness = Math.max(1, this.thickness - thicknessLost);
if (this.bodyLength < 1) {
this.bodyLength = 1;
this.isBoosting = false;
}
this.radius = this.thickness * 0.6;
} else {
this.speed = CONTROL_SNAKE_SPEED;
}
// 4) Move
const oldX = this.x;
const oldY = this.y;
this.x += this.speed * Math.cos(this.direction);
this.y += this.speed * Math.sin(this.direction);
// Check for wrapping
let wrapped = false;
if (this.x < 0) {
this.x += canvasWidth;
wrapped = true;
} else if (this.x > canvasWidth) {
this.x -= canvasWidth;
wrapped = true;
}
if (this.y < 0) {
this.y += canvasHeight;
wrapped = true;
} else if (this.y > canvasHeight) {
this.y -= canvasHeight;
wrapped = true;
}
// If we did wrap, push an edgeJump marker
if (wrapped) {
this.body.push({ x: NaN, y: NaN, edgeJump: true });
}
this.body.push({ x: this.x, y: this.y });
while (this.body.length > Math.floor(this.bodyLength)) {
this.body.shift();
}
// 5) Mutate
this.brain.mutate();
// 6) Survival bonus
this.fitness += CONTROL_SURVIVAL_BONUS;
// 7) Length bonus
if (CONTROL_LENGTH_BONUS > 0) {
this.fitness += (this.bodyLength * CONTROL_LENGTH_BONUS * 0.001);
}
// record best length
if (!maxLengths[this.id]) {
maxLengths[this.id] = { name: this.name, length: this.bodyLength, color: this.frontColor };
} else if (this.bodyLength > maxLengths[this.id].length) {
maxLengths[this.id].length = this.bodyLength;
}
}
render(ctx) {
ctx.save();
if (this.isBoosting) {
ctx.shadowBlur = 15;
ctx.shadowColor = this.frontColor;
}
const halfIndex = Math.floor(this.body.length / 2);
// front half
ctx.strokeStyle = this.frontColor;
ctx.lineWidth = this.thickness;
ctx.beginPath();
ctx.moveTo(this.body[0].x, this.body[0].y);
for (let i = 1; i < halfIndex; i++) {
const seg = this.body[i];
if (seg.edgeJump) {
// break the path
ctx.stroke();
ctx.beginPath();
if (i + 1 < halfIndex) {
ctx.moveTo(this.body[i + 1].x, this.body[i + 1].y);
i++;
}
} else {
ctx.lineTo(seg.x, seg.y);
}
}
ctx.stroke();
// back half
ctx.strokeStyle = this.backColor;
ctx.beginPath();
ctx.moveTo(this.body[halfIndex].x, this.body[halfIndex].y);
for (let i = halfIndex + 1; i < this.body.length; i++) {
const seg = this.body[i];
if (seg.edgeJump) {
ctx.stroke();
ctx.beginPath();
if (i + 1 < this.body.length) {
ctx.moveTo(this.body[i + 1].x, this.body[i + 1].y);
i++;
}
} else {
ctx.lineTo(seg.x, seg.y);
}
}
ctx.stroke();
// head
ctx.fillStyle = this.frontColor;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius * 1.3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
class Simulation {
constructor() {
this.snakes = [];
this.food = [];
this.deadSnakesBrains = [];
this.maxFoodAllowed = CONTROL_MAX_FOOD;
this.spawnFoodChance = CONTROL_SPAWN_CHANCE;
// initial snakes
for (let i = 0; i < CONTROL_SNAKE_COUNT; i++) {
this.spawnNewSnake();
}
// initial food
for (let i = 0; i < 50; i++) {
const x = Math.random() * canvasWidth;
const y = Math.random() * canvasHeight;
this.food.push(new Food(x, y, false, CONTROL_FOOD_POINTS));
}
}
spawnNewSnake() {
let newBrain;
if (this.deadSnakesBrains.length > 0) {
const b1 = this.deadSnakesBrains[Math.floor(Math.random() * this.deadSnakesBrains.length)];
const b2 = b1;
newBrain = Brain.crossover(b1, b2);
} else {
newBrain = new Brain(CONTROL_BRAIN_SIZE);
}
const x = Math.random() * canvasWidth;
const y = Math.random() * canvasHeight;
const newSnake = new Snake(x, y, newBrain);
this.snakes.push(newSnake);
maxLengths[newSnake.id] = { name: newSnake.name, length: newSnake.bodyLength, color: newSnake.frontColor };
}
removeSnakeNoRespawn(index, dropBody = false) {
const snake = this.snakes[index];
if (dropBody) {
snake.body.forEach(seg => {
if (!seg.edgeJump) {
// only add actual segments as dropped food
this.food.push(new DroppedFood(seg.x, seg.y));
}
});
}
this.snakes.splice(index, 1);
}
spawnFood() {
const x = Math.random() * canvasWidth;
const y = Math.random() * canvasHeight;
this.food.push(new Food(x, y, false, CONTROL_FOOD_POINTS));
}
killSnake(index) {
const snake = this.snakes[index];
this.deadSnakesBrains.push(snake.brain);
// drop the snake's body
snake.body.forEach(seg => {
if (!seg.edgeJump) {
this.food.push(new DroppedFood(seg.x, seg.y));
}
});
this.snakes.splice(index, 1);
// respawn
setTimeout(() => {
let newBrain;
if (this.deadSnakesBrains.length > 0) {
const b1 = this.deadSnakesBrains[Math.floor(Math.random() * this.deadSnakesBrains.length)];
const b2 = b1;
newBrain = Brain.crossover(b1, b2);
} else {
newBrain = new Brain(CONTROL_BRAIN_SIZE);
}
const x = Math.random() * canvasWidth;
const y = Math.random() * canvasHeight;
const newbie = new Snake(x, y, newBrain);
this.snakes.push(newbie);
maxLengths[newbie.id] = {
name: newbie.name,
length: newbie.bodyLength,
color: newbie.frontColor
};
}, CONTROL_RESPAWN_DELAY * 1000);
}
update(dt) {
const clampedDt = Math.min(dt, 0.05);
// spawn extra food if needed
const normalFoodCount = this.food.filter(f => !f.isDropped).length;
if (normalFoodCount < this.maxFoodAllowed / 2) {
if (Math.random() < this.spawnFoodChance) {
this.spawnFood();
}
}
// update snakes
for (let i = this.snakes.length - 1; i >= 0; i--) {
const snake = this.snakes[i];
snake.update(clampedDt, this.food, this.snakes);
// check if eats food
for (let j = this.food.length - 1; j >= 0; j--) {
const f = this.food[j];
if (distance(snake.x, snake.y, f.x, f.y) < snake.radius + f.radius) {
snake.bodyLength += CONTROL_BODY_LENGTH_INCREMENT;
snake.thickness += CONTROL_THICKNESS_INCREMENT;
snake.radius = snake.thickness * 0.6;
snake.fitness += f.worth;
this.food.splice(j, 1);
if (snake.bodyLength > maxLengths[snake.id].length) {
maxLengths[snake.id].length = snake.bodyLength;
}
}
}
// collisions with other snakes
for (let j = 0; j < this.snakes.length; j++) {
if (i === j) continue;
const other = this.snakes[j];
for (let k = 1; k < other.body.length; k++) {
const point = other.body[k];
if (point.edgeJump) continue; // skip jump markers
if (distance(snake.x, snake.y, point.x, point.y) < snake.radius + 0.5) {
if (CONTROL_KILL_BONUS > 0) {
other.fitness += CONTROL_KILL_BONUS;
}
this.killSnake(i);
break;
}
}
}
}
// update dropped food
for (let i = this.food.length - 1; i >= 0; i--) {
if (typeof this.food[i].update === 'function') {
const keep = this.food[i].update();
if (!keep) {
this.food.splice(i, 1);
}
}
}
}
render(ctx) {
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
for (const f of this.food) {
f.render(ctx);
}
for (const s of this.snakes) {
s.render(ctx);
}
}
}
/* Leaderboards & Main Loop */
let sim = null;
function updateLeaderboards() {
if (!sim) return;
// Current top 5
const currentLeaders = sim.snakes
.slice()
.sort((a, b) => b.bodyLength - a.bodyLength)
.slice(0, 5);
let currentList = "<ol>";
currentLeaders.forEach(s => {
currentList += `
<li style="color:${s.frontColor}">
<strong>${s.name}</strong>: ${s.bodyLength.toFixed(0)}
</li>
`;
});
currentList += "</ol>";
// All-time top 5
const allTimeArray = Object.values(maxLengths);
allTimeArray.sort((a, b) => b.length - a.length);
const allTimeLeaders = allTimeArray.slice(0, 5);
let allTimeList = "<ol>";
allTimeLeaders.forEach(r => {
allTimeList += `
<li style="color:${r.color}">
<strong>${r.name}</strong>: ${r.length.toFixed(0)}
</li>
`;
});
allTimeList += "</ol>";
const singleTime = `Elapsed Time: ${elapsedTime.toFixed(2)} min`;
const currCol = document.getElementById("currentLeaderboardColumn");
if (currCol) currCol.innerHTML = currentList;
const allCol = document.getElementById("allTimeLeaderboardColumn");
if (allCol) allCol.innerHTML = allTimeList;
const timeEl = document.getElementById("leaderboardTime");
if (timeEl) timeEl.innerHTML = singleTime;
}
setInterval(updateLeaderboards, 1000);
function mainLoop(timestamp) {
if (!lastFrameTime) lastFrameTime = timestamp;
const rawDt = (timestamp - lastFrameTime) / 1000;
// clamp dt to avoid giant leaps
const dt = Math.min(rawDt, 0.05);
lastFrameTime = timestamp;
elapsedTime += dt / 60;
if (sim) {
sim.update(dt);
sim.render(ctx);
}
requestAnimationFrame(mainLoop);
}
requestAnimationFrame(mainLoop);
function init() {
resetSimulation();
updateLayout();
attachTopLevelTabLogic(document.getElementById("controlPanel"));
attachRewardSubTabLogic(document.getElementById("controlPanel"));
attachTopLevelTabLogic(document.getElementById("timeAndLeaderboardPanel"));
wireUIEvents();
}
init();