gummyarena/script.js

1222 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===========================
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 = 1;
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 = 20;
const DEFAULT_LENGTH_BONUS = 3;
const DEFAULT_KILL_BONUS = 3;
/* 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;
const newIsMobile = (width < 1000);
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);
// RE-wire help button in the new clone
const helpBtn = document.getElementById('helpButton');
const helpModal = document.getElementById('helpModal');
const helpClose = document.getElementById('helpClose');
if (helpBtn && helpModal && helpClose) {
helpBtn.onclick = () => { helpModal.style.display = 'flex'; };
helpClose.onclick = () => { helpModal.style.display = 'none'; };
}
}
}
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);
// new calls for sub-tabs:
attachSnakesSubTabLogic(clone);
attachWorldSubTabLogic(clone);
wireUIEvents();
// If this is the controlPanel AND we are in mobile, minimize again
if (isMobileLayout && clone.id === 'controlPanel') {
const cContent = clone.querySelector('.content');
const cMinBtn = clone.querySelector('.minimize');
if (cContent && cMinBtn) {
cContent.style.display = 'none';
cMinBtn.textContent = '+';
}
}
}
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');
});
});
}
/* NEW: Attach snakes sub-tab logic */
function attachSnakesSubTabLogic(panel) {
const snakesTab = panel.querySelector('#snakesTab');
if (!snakesTab) return;
const subTabButtons = snakesTab.querySelectorAll('.snakes-subtabs .tabBtn');
subTabButtons.forEach(subBtn => {
subBtn.addEventListener('click', () => {
subTabButtons.forEach(b => b.classList.remove('active'));
const allSubContents = snakesTab.querySelectorAll('.snakes-sub-content');
allSubContents.forEach(s => s.classList.remove('active'));
subBtn.classList.add('active');
const subId = subBtn.getAttribute('data-subtab');
const targetSub = snakesTab.querySelector('#' + subId);
if (targetSub) {
targetSub.classList.add('active');
}
});
});
}
/* NEW: Attach world sub-tab logic */
function attachWorldSubTabLogic(panel) {
const worldTab = panel.querySelector('#worldTab');
if (!worldTab) return;
const subTabButtons = worldTab.querySelectorAll('.world-subtabs .tabBtn');
subTabButtons.forEach(subBtn => {
subBtn.addEventListener('click', () => {
subTabButtons.forEach(b => b.classList.remove('active'));
const allSubContents = worldTab.querySelectorAll('.world-sub-content');
allSubContents.forEach(s => s.classList.remove('active'));
subBtn.classList.add('active');
const subId = subBtn.getAttribute('data-subtab');
const targetSub = worldTab.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 => {
CONTROL_SNAKE_COUNT = parseInt(e.target.value);
updateControlValue('snakeCountVal', e.target.value);
adjustSnakeCount(e.target.value)
};
}
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;
updateControlValue('foodPointsVal', DEFAULT_FOOD_POINTS);
updateControlValue('droppedFoodPointsVal', DEFAULT_DROPPED_FOOD_POINTS);
updateControlValue('killBonusVal', DEFAULT_KILL_BONUS);
updateControlValue('survivalBonusVal', DEFAULT_SURVIVAL_BONUS);
updateControlValue('lengthBonusVal', DEFAULT_LENGTH_BONUS);
updateControlValue('maxFoodVal', DEFAULT_MAX_FOOD);
updateControlValue('spawnChanceVal', DEFAULT_SPAWN_CHANCE);
updateControlValue('bodyLengthIncVal', DEFAULT_BODY_LENGTH_INCREMENT);
updateControlValue('thicknessIncVal', DEFAULT_THICKNESS_INCREMENT);
updateControlValue('snakeCountVal', DEFAULT_SNAKE_COUNT);
updateControlValue('snakeSpeedVal', DEFAULT_SNAKE_SPEED);
updateControlValue('brainSizeVal', DEFAULT_BRAIN_SIZE);
updateControlValue('turnRateVal', DEFAULT_TURN_RATE);
updateControlValue('boostCostVal', DEFAULT_BOOST_COST);
updateControlValue('boostMultiplierVal', DEFAULT_BOOST_MULTIPLIER);
updateControlValue('mutationRateVal', DEFAULT_MUTATION_RATE);
updateControlValue('crossoverBiasVal', DEFAULT_CROSSOVER_BIAS);
updateControlValue('respawnDelayVal', DEFAULT_RESPAWN_DELAY);
updateControlValue('foodDecayTimeVal', DEFAULT_FOOD_DECAY_TIME);
// Wire up the help button to open the modal
const helpBtn = document.getElementById('helpButton');
const helpModal = document.getElementById('helpModal');
const helpClose = document.getElementById('helpClose');
if (helpBtn && helpModal && helpClose) {
helpBtn.onclick = () => {
helpModal.style.display = 'flex';
};
helpClose.onclick = () => {
helpModal.style.display = 'none';
};
}
}
/* 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 = DEFAULT_BRAIN_SIZE;
CONTROL_SNAKE_SPEED = DEFAULT_SNAKE_SPEED;
CONTROL_MAX_FOOD = DEFAULT_MAX_FOOD;
CONTROL_SPAWN_CHANCE = DEFAULT_SPAWN_CHANCE;
CONTROL_FOOD_POINTS = DEFAULT_FOOD_POINTS;
CONTROL_DROPPED_FOOD_POINTS = DEFAULT_DROPPED_FOOD_POINTS;
CONTROL_SURVIVAL_BONUS = DEFAULT_SURVIVAL_BONUS;
CONTROL_BODY_LENGTH_INCREMENT = DEFAULT_BODY_LENGTH_INCREMENT;
CONTROL_THICKNESS_INCREMENT = DEFAULT_THICKNESS_INCREMENT;
CONTROL_SNAKE_COUNT = DEFAULT_SNAKE_COUNT;
CONTROL_LENGTH_BONUS = DEFAULT_LENGTH_BONUS;
CONTROL_KILL_BONUS = DEFAULT_KILL_BONUS;
CONTROL_MUTATION_RATE = DEFAULT_MUTATION_RATE;
CONTROL_CROSSOVER_BIAS = DEFAULT_CROSSOVER_BIAS;
CONTROL_FOOD_DECAY_TIME = DEFAULT_FOOD_DECAY_TIME;
CONTROL_RESPAWN_DELAY = DEFAULT_RESPAWN_DELAY;
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"));
attachSnakesSubTabLogic(document.getElementById("controlPanel"));
attachWorldSubTabLogic(document.getElementById("controlPanel"));
attachTopLevelTabLogic(document.getElementById("timeAndLeaderboardPanel"));
wireUIEvents();
}
init();