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;
|
2025-03-15 18:33:29 +00:00
|
|
|
|
const DEFAULT_SNAKE_SPEED = 1;
|
2025-03-15 06:49:30 +00:00
|
|
|
|
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;
|
2025-03-15 18:33:29 +00:00
|
|
|
|
const DEFAULT_SNAKE_COUNT = 15;
|
|
|
|
|
|
const DEFAULT_LENGTH_BONUS = 3;
|
|
|
|
|
|
const DEFAULT_KILL_BONUS = 3;
|
2025-03-15 06:49:30 +00:00
|
|
|
|
|
|
|
|
|
|
/* 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);
|
2025-03-15 18:33:29 +00:00
|
|
|
|
|
|
|
|
|
|
// new calls for sub-tabs:
|
|
|
|
|
|
attachSnakesSubTabLogic(clone);
|
|
|
|
|
|
attachWorldSubTabLogic(clone);
|
|
|
|
|
|
|
2025-03-15 06:49:30 +00:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-03-15 18:33:29 +00:00
|
|
|
|
/* 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-03-15 06:49:30 +00:00
|
|
|
|
|
|
|
|
|
|
/* 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"));
|
2025-03-15 18:33:29 +00:00
|
|
|
|
attachSnakesSubTabLogic(document.getElementById("controlPanel"));
|
|
|
|
|
|
attachWorldSubTabLogic(document.getElementById("controlPanel"));
|
2025-03-15 06:49:30 +00:00
|
|
|
|
attachTopLevelTabLogic(document.getElementById("timeAndLeaderboardPanel"));
|
|
|
|
|
|
|
|
|
|
|
|
wireUIEvents();
|
2025-03-15 18:33:29 +00:00
|
|
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-03-15 06:49:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
init();
|