feat: update

This commit is contained in:
2026-06-11 18:28:25 +08:00
parent 8520f9db3c
commit 8a2fb36a98
65 changed files with 4407 additions and 572 deletions
+1209
View File
@@ -0,0 +1,1209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Candy Pump Survival — Gameplay Visualization</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #eee;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
h1 {
margin: 12px 0 4px;
font-size: 22px;
color: #ff99cc;
text-shadow: 0 0 12px #ff66b288;
letter-spacing: 1px;
}
.subtitle {
font-size: 12px;
color: #888;
margin-bottom: 8px;
}
#top-bar {
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 6px;
flex-wrap: wrap;
justify-content: center;
}
.stat-box {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 6px 14px;
text-align: center;
min-width: 90px;
}
.stat-box .label { font-size: 10px; color: #888; text-transform: uppercase; }
.stat-box .value { font-size: 22px; font-weight: bold; }
.stat-box .value.timer { color: #ffcc33; }
.stat-box .value.phase1 { color: #ff99cc; }
.stat-box .value.phase2 { color: #ffcc33; }
.stat-box .value.phase3 { color: #ff4d4d; }
.stat-box .value.score { color: #61ff54; }
.stat-box .value.sticky { color: #ff66b2; }
#game-container {
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
}
canvas {
border: 2px solid #333;
border-radius: 4px;
cursor: crosshair;
}
#sidebar {
width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 10px;
}
.panel h3 {
font-size: 12px;
color: #ff99cc;
text-transform: uppercase;
margin-bottom: 6px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.player-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 13px;
}
.player-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #000;
flex-shrink: 0;
}
.player-name { flex: 1; }
.player-score { font-weight: bold; color: #61ff54; font-size: 15px; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #aaa;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid #555;
}
.controls {
font-size: 11px;
color: #666;
margin-top: 4px;
line-height: 1.6;
}
.controls kbd {
background: #222;
border: 1px solid #444;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #ccc;
}
#bottom-bar {
display: flex;
gap: 16px;
margin-top: 8px;
flex-wrap: wrap;
justify-content: center;
}
.log-entry {
font-size: 11px;
color: #888;
padding: 1px 0;
}
.log-entry.trap { color: #ff4d4d; }
.log-entry.cleanse { color: #66e6ff; }
.log-entry.bubble { color: #ff99cc; }
.log-entry.score { color: #61ff54; }
.log-entry.phase { color: #ffcc33; }
</style>
</head>
<body>
<h1>Candy Pump Survival</h1>
<div class="subtitle">Gauntlet Mode — Gameplay Visualization</div>
<div id="top-bar">
<div class="stat-box"><div class="label">Timer</div><div class="value timer" id="timer">3:00</div></div>
<div class="stat-box"><div class="label">Phase</div><div class="value phase1" id="phase-label">OUTER PRESSURE</div></div>
<div class="stat-box"><div class="label">Coverage</div><div class="value sticky" id="coverage">0%</div></div>
<div class="stat-box"><div class="label">Growth Tick</div><div class="value" id="tick-count" style="color:#ff99cc">0</div></div>
<div class="stat-box"><div class="label">Bubbles</div><div class="value" id="bubble-count" style="color:#ff66cc">0</div></div>
</div>
<div id="game-container">
<canvas id="canvas"></canvas>
<div id="sidebar">
<div class="panel">
<h3>Players</h3>
<div id="player-list"></div>
</div>
<div class="panel">
<h3>Legend</h3>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#4f4f4f"></div>Safe</div>
<div class="legend-item"><div class="legend-swatch" style="background:#d6268a"></div>Sticky</div>
<div class="legend-item"><div class="legend-swatch" style="background:#e8a020"></div>Telegraph</div>
<div class="legend-item"><div class="legend-swatch" style="background:#ff5722"></div>Bubble</div>
<div class="legend-item"><div class="legend-swatch" style="background:#cc3380"></div>NPC</div>
<div class="legend-item"><div class="legend-swatch" style="background:#d10000"></div>Wall</div>
<div class="legend-item"><div class="legend-swatch" style="background:#66e6ff"></div>Cleansed</div>
<div class="legend-item"><div class="legend-swatch" style="background:#e8ba38"></div>Mission</div>
</div>
</div>
<div class="panel">
<h3>Controls</h3>
<div class="controls">
All 4 players are autonomous bots.<br>
<kbd>R</kbd> Restart simulation<br>
<kbd>P</kbd> Pause / resume<br>
<kbd>D</kbd> Toggle bot paths
</div>
</div>
</div>
</div>
<div id="bottom-bar">
<div class="panel" style="min-width:500px;max-width:700px">
<h3>Event Log</h3>
<div id="log" style="max-height:120px;overflow-y:auto;font-size:11px;"></div>
</div>
</div>
<script>
// ── Constants ──
const ARENA = 24;
const NPC_CENTER = { x: 11, y: 11 };
const NPC_SIZE = 3;
const CELL_PX = 26;
const WALL = 4, WALKABLE = 0, STICKY = 17, TELEGRAPH = 18, NPC_BLOCK = 5;
const TOTAL_PLAYABLE = ARENA * ARENA - NPC_SIZE * NPC_SIZE;
const PHASE_NAMES = ['OUTER PRESSURE', 'MIDDLE PRESSURE', 'INNER SURVIVAL'];
const PHASE_CLASSES = ['phase1', 'phase2', 'phase3'];
const PHASE_GROWTH = [
{ min: 4, max: 6, bubbles: 0 },
{ min: 6, max: 8, bubbles: 2 },
{ min: 8, max: 10, bubbles: 3 },
];
const PLAYER_COLORS = ['#33cc33', '#4d80ff', '#ff9933', '#cc66ff'];
const PLAYER_NAMES = ['Pip', 'Dabro', 'Gatot', 'Copper'];
// ── Canvas ──
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = ARENA * CELL_PX;
canvas.height = ARENA * CELL_PX;
// ── Game State ──
let grid = []; // 0=walkable, 4=wall, 5=npc, 17=sticky, 18=telegraph, 6=cleansed
let cellScores = []; // candidate scores for debug overlay
let missionTiles = {}; // "x,y" -> true if has mission tile
let players = [];
let elapsed = 0;
let paused = false;
let growthTimer = 0;
let totalTicks = 0;
let bubblesSpawned = 0;
let activeBubbles = []; // {x, y, growTimer, maxGrow}
let recentBubbles = [];
let cleanserCount = [0, 0, 0, 0];
let cleanserActive = [false, false, false, false];
let cleanserCellsLeft = [0, 0, 0, 0];
let trapped = [false, false, false, false];
let scores = [0, 0, 0, 0];
let missions = [0, 0, 0, 0];
let logs = [];
let currentPhase = 0;
let showPaths = false;
// ── Initialization ──
function initGrid() {
grid = [];
cellScores = [];
for (let y = 0; y < ARENA; y++) {
grid[y] = [];
cellScores[y] = [];
for (let x = 0; x < ARENA; x++) {
// Walls on perimeter
if (x === 0 || y === 0 || x === ARENA - 1 || y === ARENA - 1) {
grid[y][x] = WALL;
}
// NPC center 3x3
else if (x >= NPC_CENTER.x - 1 && x <= NPC_CENTER.x + 1 &&
y >= NPC_CENTER.y - 1 && y <= NPC_CENTER.y + 1) {
grid[y][x] = NPC_BLOCK;
}
else {
grid[y][x] = WALKABLE;
}
cellScores[y][x] = 0;
}
}
}
function initMissionTiles() {
missionTiles = {};
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (grid[y][x] === WALKABLE) {
missionTiles[`${x},${y}`] = true;
}
}
}
}
function initPlayers() {
const spawns = [
{ x: 2, y: 2 }, { x: ARENA - 3, y: 2 },
{ x: 2, y: ARENA - 3 }, { x: ARENA - 3, y: ARENA - 3 }
];
players = [];
for (let i = 0; i < 4; i++) {
players.push({
x: spawns[i].x, y: spawns[i].y,
targetX: spawns[i].x, targetY: spawns[i].y,
moving: false, moveProgress: 0,
startX: spawns[i].x, startY: spawns[i].y
});
}
}
function resetGame() {
elapsed = 0;
growthTimer = 0;
totalTicks = 0;
bubblesSpawned = 0;
activeBubbles = [];
recentBubbles = [];
currentPhase = 0;
cleanserCount = [0, 0, 0, 0];
cleanserActive = [false, false, false, false];
cleanserCellsLeft = [0, 0, 0, 0];
trapped = [false, false, false, false];
scores = [0, 0, 0, 0];
missions = [0, 0, 0, 0];
logs = [];
initGrid();
initMissionTiles();
initPlayers();
addLog('Game started! Collect tiles and survive the candy.', 'phase');
updateUI();
}
// ── Logging ──
function addLog(msg, cls = '') {
logs.unshift({ msg, cls, time: formatTime(elapsed) });
if (logs.length > 50) logs.pop();
renderLog();
}
function renderLog() {
const el = document.getElementById('log');
el.innerHTML = logs.slice(0, 15).map(l =>
`<div class="log-entry ${l.cls}">[${l.time}] ${l.msg}</div>`
).join('');
}
// ── Layer / Edge Distance ──
function getLayer(x, y) {
const edge = Math.min(x, y, ARENA - 1 - x, ARENA - 1 - y);
if (edge <= 3) return 'outer';
if (edge <= 7) return 'middle';
return 'inner';
}
function getEdgeDistance(x, y) {
return Math.min(x, y, ARENA - 1 - x, ARENA - 1 - y);
}
// ── Candidate Scoring ──
function isCellValidForGrowth(x, y) {
if (x < 0 || y < 0 || x >= ARENA || y >= ARENA) return false;
return grid[y][x] === WALKABLE || grid[y][x] === 6; // walkable or cleansed
}
function getNeighbors8(x, y) {
const n = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < ARENA && ny < ARENA) n.push({ x: nx, y: ny });
}
}
return n;
}
function countStickyNeighbors(x, y) {
return getNeighbors8(x, y).filter(n => grid[n.y][n.x] === STICKY).length;
}
function expandsStickyCluster(x, y) {
const neighbors = getNeighbors8(x, y);
return neighbors.filter(n => grid[n.y][n.x] === STICKY).length >= 2;
}
function connectsStickyClusters(x, y) {
// Check if placing sticky here connects two separate sticky regions
const neighbors = getNeighbors8(x, y).filter(n => grid[n.y][n.x] === STICKY);
if (neighbors.length < 2) return false;
// Simple heuristic: multiple sticky neighbors from different directions
const dirs = neighbors.map(n => Math.atan2(n.y - y, n.x - x));
const uniqueDirs = new Set(dirs.map(d => Math.round(d * 10) / 10));
return uniqueDirs.size >= 3;
}
function distToCenter(x, y) {
return Math.sqrt((x - NPC_CENTER.x) ** 2 + (y - NPC_CENTER.y) ** 2);
}
function distToNearestPlayer(x, y) {
let minD = Infinity;
for (const p of players) {
if (!trapped[players.indexOf(p)]) {
const d = Math.sqrt((x - p.x) ** 2 + (y - p.y) ** 2);
if (d < minD) minD = d;
}
}
return minD;
}
function wouldTrapPlayer(x, y) {
// Simplified: check if any active player would have no safe neighbors
for (let i = 0; i < 4; i++) {
if (trapped[i]) continue;
const p = players[i];
const pdist = Math.sqrt((x - p.x) ** 2 + (y - p.y) ** 2);
if (pdist > 8) continue;
// Count safe neighbors of player
const safeNeighbors = getNeighbors8(Math.round(p.x), Math.round(p.y))
.filter(n => grid[n.y][n.x] === WALKABLE || grid[n.y][n.x] === 6);
if (safeNeighbors.length <= 1) return true;
}
return false;
}
function calculateCandidateScore(x, y) {
let score = 0;
const layer = getLayer(x, y);
// LayerPriority
const layerScores = [
{ outer: 60, middle: 15, inner: -40 },
{ outer: 20, middle: 60, inner: 5 },
{ outer: 10, middle: 35, inner: 60 },
];
score += layerScores[currentPhase][layer] || 0;
// StickyNeighborScore
score += countStickyNeighbors(x, y) * 8;
// InwardPressureScore
const centerDist = distToCenter(x, y);
const maxDist = distToCenter(1, 1);
const inwardRatio = 1 - centerDist / maxDist;
const inwardScores = [
[0, 10], [5, 20], [10, 30]
];
score += inwardScores[currentPhase][0] + (inwardScores[currentPhase][1] - inwardScores[currentPhase][0]) * inwardRatio;
// PlayerPressureScore
const playerDist = distToNearestPlayer(x, y);
if (playerDist >= 2 && playerDist <= 4) score += 20;
else if (playerDist < 1) score += elapsed > 150 ? 10 : -50;
// ClusterGrowthScore
if (connectsStickyClusters(x, y)) score += 25;
else if (expandsStickyCluster(x, y)) score += 15;
// RandomNoise
score += (Math.random() * 40 - 20);
// PathSafetyPenalty
if (wouldTrapPlayer(x, y) && elapsed < 150) score -= 100;
// RepetitionPenalty (simplified)
const neighbors = getNeighbors8(x, y);
const recentTargets = neighbors.filter(n => grid[n.y][n.x] === TELEGRAPH);
if (recentTargets.length > 0) score -= 30;
return score;
}
// ── Growth Tick ──
function processGrowthTick() {
const config = PHASE_GROWTH[currentPhase];
const cellCount = config.min + Math.floor(Math.random() * (config.max - config.min + 1));
// Generate candidates
const candidates = [];
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (!isCellValidForGrowth(x, y)) continue;
const score = calculateCandidateScore(x, y);
cellScores[y][x] = score;
candidates.push({ x, y, score: score + 100 }); // offset for positive weights
}
}
if (candidates.length === 0) return;
// Weighted random selection
candidates.sort((a, b) => b.score - a.score);
let totalWeight = candidates.reduce((s, c) => s + c.score, 0);
const selected = [];
const used = new Set();
for (let i = 0; i < Math.min(cellCount, candidates.length); i++) {
let roll = Math.random() * totalWeight;
let cumulative = 0;
for (const c of candidates) {
if (used.has(`${c.x},${c.y}`)) continue;
cumulative += c.score;
if (roll <= cumulative) {
selected.push(c);
used.add(`${c.x},${c.y}`);
totalWeight -= c.score;
break;
}
}
}
// Telegraph phase
for (const c of selected) {
grid[c.y][c.x] = TELEGRAPH;
}
totalTicks++;
// After telegraph delay, apply sticky
setTimeout(() => {
for (const c of selected) {
if (grid[c.y][c.x] === TELEGRAPH) {
grid[c.y][c.x] = STICKY;
// Check players on this cell
for (let i = 0; i < 4; i++) {
if (!trapped[i] && Math.round(players[i].x) === c.x && Math.round(players[i].y) === c.y) {
if (cleanserActive[i]) {
grid[c.y][c.x] = 6; // cleansed
cleanserCellsLeft[i]--;
if (cleanserCellsLeft[i] <= 0) {
cleanserActive[i] = false;
addLog(`${PLAYER_NAMES[i]}'s Cleanser wore off.`, 'cleanse');
}
addLog(`${PLAYER_NAMES[i]} cleansed a sticky cell!`, 'cleanse');
} else {
trapPlayer(i);
}
}
}
}
}
// Clear telegraph visuals after impact
setTimeout(() => {
for (const c of selected) {
if (grid[c.y][c.x] === TELEGRAPH) grid[c.y][c.x] = STICKY;
}
// Invalidate bot paths — grid changed
botPaths = [[], [], [], []];
}, 200);
}, 800);
}
// ── Candy Bubble System ──
function trySpawnBubble() {
const config = PHASE_GROWTH[currentPhase];
if (bubblesSpawned >= config.bubbles) return;
if (Math.random() > 0.3) return; // chance per tick
// Find valid bubble candidates
const candidates = [];
for (let y = 3; y < ARENA - 3; y++) {
for (let x = 3; x < ARENA - 3; x++) {
if (!isCellValidForGrowth(x, y)) continue;
// Check 3x3 area is mostly clear
let clear = true;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!isCellValidForGrowth(x + dx, y + dy)) { clear = false; break; }
}
if (!clear) break;
}
if (!clear) continue;
// Camping bonus
let score = 30;
for (let i = 0; i < 4; i++) {
const pdist = Math.sqrt((x - players[i].x) ** 2 + (y - players[i].y) ** 2);
if (pdist < 1.5) score -= 60; // don't spawn on player
else if (pdist <= 4) score += 20;
}
// Recent bubble penalty
for (const rb of recentBubbles) {
if (Math.sqrt((x - rb.x) ** 2 + (y - rb.y) ** 2) < 5) {
score -= 50;
break;
}
}
// Untouched area bonus
const clusterSize = floodFillCount(x, y, new Set());
if (clusterSize > 10) score += 30;
score += Math.random() * 40 - 20;
candidates.push({ x, y, score });
}
}
if (candidates.length === 0) return;
candidates.sort((a, b) => b.score - a.score);
const target = candidates[0];
activeBubbles.push({
x: target.x, y: target.y,
growTimer: 2.5 + Math.random() * 0.5,
maxGrow: 2.5 + Math.random() * 0.5
});
bubblesSpawned++;
recentBubbles.push({ x: target.x, y: target.y });
if (recentBubbles.length > 8) recentBubbles.shift();
addLog(`Candy bubble appeared near (${target.x},${target.y})!`, 'bubble');
}
function floodFillCount(x, y, visited) {
const key = `${x},${y}`;
if (visited.has(key)) return 0;
if (!isCellValidForGrowth(x, y)) return 0;
visited.add(key);
let count = 1;
for (const n of getNeighbors8(x, y)) {
count += floodFillCount(n.x, n.y, visited);
}
return count;
}
function processBubbles(dt) {
for (let i = activeBubbles.length - 1; i >= 0; i--) {
const b = activeBubbles[i];
b.growTimer -= dt;
if (b.growTimer <= 0) {
// Explode into 3x3
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = b.x + dx, ny = b.y + dy;
if (isCellValidForGrowth(nx, ny)) {
grid[ny][nx] = STICKY;
// Check players
for (let j = 0; j < 4; j++) {
if (!trapped[j] && Math.round(players[j].x) === nx && Math.round(players[j].y) === ny) {
if (cleanserActive[j]) {
grid[ny][nx] = 6;
cleanserCellsLeft[j]--;
if (cleanserCellsLeft[j] <= 0) {
cleanserActive[j] = false;
addLog(`${PLAYER_NAMES[j]}'s Cleanser wore off.`, 'cleanse');
}
} else {
trapPlayer(j);
}
}
}
}
}
}
addLog(`Bubble exploded at (${b.x},${b.y})!`, 'trap');
activeBubbles.splice(i, 1);
botPaths = [[], [], [], []];
}
}
}
// ── Player Actions ──
function trapPlayer(idx) {
// Auto-use cleanser if available
if (cleanserCount[idx] > 0 && !cleanserActive[idx]) {
cleanserCount[idx]--;
cleanserActive[idx] = true;
cleanserCellsLeft[idx] = 5;
trapped[idx] = false;
addLog(`${PLAYER_NAMES[idx]} auto-activated Cleanser to escape!`, 'cleanse');
return;
}
trapped[idx] = true;
botPaths[idx] = [];
addLog(`${PLAYER_NAMES[idx]} is TRAPPED in sticky candy!`, 'trap');
scores[idx] = Math.max(0, scores[idx] - 50);
}
// ── Movement ──
function canMoveTo(idx, x, y) {
if (x < 1 || y < 1 || x >= ARENA - 1 || y >= ARENA - 1) return false;
const cell = grid[y][x];
if (cell === WALL || cell === NPC_BLOCK) return false;
if (cell === STICKY && !cleanserActive[idx]) return false;
return true;
}
function movePlayerStep(idx, dx, dy) {
if (trapped[idx]) return;
const p = players[idx];
if (p.moving) return; // already moving
const nx = Math.round(p.x) + dx;
const ny = Math.round(p.y) + dy;
if (!canMoveTo(idx, nx, ny)) return;
p.startX = Math.round(p.x);
p.startY = Math.round(p.y);
p.targetX = nx;
p.targetY = ny;
p.moveProgress = 0;
p.moving = true;
p.moveDir = { dx, dy };
}
function updatePlayerMovement(dt) {
const speed = 10; // cells per second — fast and responsive
for (let i = 0; i < 4; i++) {
const p = players[i];
if (!p.moving) continue;
p.moveProgress += dt * speed;
if (p.moveProgress >= 1) {
p.x = p.targetX;
p.y = p.targetY;
p.moving = false;
p.moveProgress = 0;
// Collect mission tile
const key = `${Math.round(p.x)},${Math.round(p.y)}`;
if (missionTiles[key]) {
delete missionTiles[key];
missions[i]++;
scores[i] += 1000;
addLog(`${PLAYER_NAMES[i]} collected a mission tile! (${missions[i]} total)`, 'score');
if (missions[i] % 2 === 0 && cleanserCount[i] < 1) {
cleanserCount[i]++;
addLog(`${PLAYER_NAMES[i]} earned a Cleanser!`, 'cleanse');
}
}
// Check if landed on sticky
const gx = Math.round(p.x), gy = Math.round(p.y);
if (grid[gy][gx] === STICKY) {
if (cleanserActive[i]) {
grid[gy][gx] = 6; // cleansed
cleanserCellsLeft[i]--;
if (cleanserCellsLeft[i] <= 0) {
cleanserActive[i] = false;
addLog(`${PLAYER_NAMES[i]}'s Cleanser wore off.`, 'cleanse');
} else {
addLog(`${PLAYER_NAMES[i]} cleansed a cell (${cleanserCellsLeft[i]} left)`, 'cleanse');
}
} else {
trapPlayer(i);
}
}
} else {
p.x = p.startX + (p.targetX - p.startX) * p.moveProgress;
p.y = p.startY + (p.targetY - p.startY) * p.moveProgress;
}
}
}
// ── Phase Management ──
function updatePhase() {
let newPhase = 0;
if (elapsed >= 120) newPhase = 2;
else if (elapsed >= 60) newPhase = 1;
if (newPhase !== currentPhase) {
currentPhase = newPhase;
addLog(`Phase changed: ${PHASE_NAMES[currentPhase]}`, 'phase');
}
}
// ── Cooldowns ──
function updateCooldowns(_dt) {
}
// ── Bot AI ──
let botTimers = [0, 0, 0, 0];
let botPaths = [[], [], [], []];
function isWalkable(x, y) {
if (x < 0 || y < 0 || x >= ARENA || y >= ARENA) return false;
const c = grid[y][x];
return c === WALKABLE || c === 6;
}
function astar(sx, sy, tx, ty) {
if (sx === tx && sy === ty) return [];
const k = (x, y) => `${x},${y}`;
const open = new Map();
const closed = new Set();
const h = (x, y) => Math.abs(x - tx) + Math.abs(y - ty);
open.set(k(sx, sy), { x: sx, y: sy, g: 0, f: h(sx, sy), p: null });
while (open.size > 0) {
let best = null;
for (const n of open.values()) {
if (!best || n.f < best.f || (n.f === best.f && n.g > best.g)) best = n;
}
if (best.x === tx && best.y === ty) {
const path = [];
let c = best;
while (c.p) { path.unshift({ x: c.x, y: c.y }); c = c.p; }
return path;
}
open.delete(k(best.x, best.y));
closed.add(k(best.x, best.y));
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const nx = best.x + dx, ny = best.y + dy;
const nk = k(nx, ny);
if (closed.has(nk)) continue;
if (!(nx === tx && ny === ty) && !isWalkable(nx, ny)) continue;
const g = best.g + 1;
const ex = open.get(nk);
if (ex && g >= ex.g) continue;
open.set(nk, { x: nx, y: ny, g, f: g + h(nx, ny), p: best });
}
}
return [];
}
function findNearestMission(idx) {
const p = players[idx];
const cx = Math.round(p.x), cy = Math.round(p.y);
let best = null, bestDist = Infinity;
for (const key in missionTiles) {
const [mx, my] = key.split(',').map(Number);
const d = Math.abs(mx - cx) + Math.abs(my - cy);
if (d < bestDist) { bestDist = d; best = { x: mx, y: my }; }
}
return best;
}
function botStep(idx) {
if (trapped[idx]) return;
const p = players[idx];
if (p.moving) return;
const path = botPaths[idx];
if (path.length > 0) {
const next = path[0];
if (isWalkable(next.x, next.y)) {
movePlayerStep(idx, next.x - Math.round(p.x), next.y - Math.round(p.y));
if (!p.moving) {
path.shift();
// Stuck: couldn't move to next path node, invalidate
if (path.length > 0) botPaths[idx] = [];
}
} else {
path.shift();
botPaths[idx] = [];
}
return;
}
// Try A* to nearest mission
const target = findNearestMission(idx);
if (target) {
botPaths[idx] = astar(Math.round(p.x), Math.round(p.y), target.x, target.y);
if (botPaths[idx].length > 0) {
const next = botPaths[idx].shift();
movePlayerStep(idx, next.x - Math.round(p.x), next.y - Math.round(p.y));
return;
}
}
// FALLBACK: no path to any mission — move away from nearest sticky (survival)
escapeSticky(idx);
}
function escapeSticky(idx) {
const p = players[idx];
const cx = Math.round(p.x), cy = Math.round(p.y);
// Find nearest sticky cell
let nearestSticky = null, nearDist = Infinity;
for (let y = 0; y < ARENA; y++) {
for (let x = 0; x < ARENA; x++) {
if (grid[y][x] === STICKY) {
const d = Math.abs(x - cx) + Math.abs(y - cy);
if (d < nearDist) { nearDist = d; nearestSticky = { x, y }; }
}
}
}
if (!nearestSticky) return;
// Move away from sticky: try the direction opposite to it
const dx = Math.sign(cx - nearestSticky.x);
const dy = Math.sign(cy - nearestSticky.y);
const dirs = [[dx, 0], [0, dy], [-dx, 0], [0, -dy], [1, 0], [-1, 0], [0, 1], [0, -1]];
for (const [mx, my] of dirs) {
if (mx === 0 && my === 0) continue;
if (canMoveTo(idx, cx + mx, cy + my)) {
movePlayerStep(idx, mx, my);
return;
}
}
}
function updateBots(dt) {
for (let i = 0; i < 4; i++) {
botTimers[i] -= dt;
if (botTimers[i] <= 0) {
botStep(i);
botTimers[i] = 0.12;
}
}
}
// ── Scoring ──
function updateScoring() {
// Survival bonus at end
}
// ── Rendering ──
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < ARENA; y++) {
for (let x = 0; x < ARENA; x++) {
const px = x * CELL_PX;
const py = y * CELL_PX;
const cell = grid[y][x];
// Base cell color
let color;
switch (cell) {
case WALKABLE: color = '#4f4f4f'; break;
case WALL: color = '#d10000'; break;
case NPC_BLOCK: color = '#cc3380'; break;
case STICKY: color = '#d6268a'; break; // deep magenta — set candy
case TELEGRAPH: color = '#e8a020'; break; // amber/syrup — warning glow
case 6: color = '#66e6ff'; break; // cleansed
default: color = '#4f4f4f';
}
// Layer highlight based on current pressure
if (cell === WALKABLE || cell === 6) {
const layer = getLayer(x, y);
const targetLayer = currentPhase === 0 ? 'outer' : currentPhase === 1 ? 'middle' : 'inner';
if (layer === targetLayer) {
// Subtle highlight for current pressure layer
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
color = `rgb(${Math.min(255, r + 12)},${Math.min(255, g + 8)},${Math.min(255, b + 5)})`;
}
}
ctx.fillStyle = color;
ctx.fillRect(px, py, CELL_PX - 1, CELL_PX - 1);
// Draw grid lines
ctx.strokeStyle = '#222';
ctx.lineWidth = 0.5;
ctx.strokeRect(px, py, CELL_PX, CELL_PX);
// Mission tiles (fully fill cell)
if (missionTiles[`${x},${y}`] && (cell === WALKABLE || cell === 6)) {
ctx.fillStyle = '#e8ba38';
ctx.fillRect(px + 1, py + 1, CELL_PX - 2, CELL_PX - 2);
ctx.strokeStyle = '#b8901a';
ctx.lineWidth = 1;
ctx.strokeRect(px + 1, py + 1, CELL_PX - 2, CELL_PX - 2);
// Shine highlight
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(px + 3, py + 3, (CELL_PX - 6) * 0.4, (CELL_PX - 6) * 0.35);
}
// Telegraph pulsing effect (amber/syrup glow)
if (cell === TELEGRAPH) {
const pulse = 0.35 + Math.sin(Date.now() / 120) * 0.3;
ctx.fillStyle = `rgba(232,160,32,${pulse})`;
ctx.fillRect(px, py, CELL_PX - 1, CELL_PX - 1);
}
}
}
// Draw layer borders (subtle)
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (getEdgeDistance(x, y) === 4 || getEdgeDistance(x, y) === 8) {
const px = x * CELL_PX;
const py = y * CELL_PX;
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(px, py + CELL_PX);
ctx.stroke();
}
}
}
// Draw active bubbles (hot orange-red)
for (const b of activeBubbles) {
const px = b.x * CELL_PX;
const py = b.y * CELL_PX;
const progress = 1 - b.growTimer / b.maxGrow;
const size = CELL_PX * (0.3 + progress * 0.7);
// Warning 3x3 area
if (progress > 0.5) {
ctx.fillStyle = `rgba(255,87,34,${(progress - 0.5) * 0.25})`;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
ctx.fillRect((b.x + dx) * CELL_PX, (b.y + dy) * CELL_PX, CELL_PX - 1, CELL_PX - 1);
}
}
}
// Bubble itself
ctx.fillStyle = `rgba(255,87,34,${0.6 + progress * 0.4})`;
ctx.beginPath();
ctx.arc(px + CELL_PX / 2, py + CELL_PX / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ff8a50';
ctx.lineWidth = 2;
ctx.stroke();
// Pulse ring
if (progress > 0.7) {
const pulseSize = size * (1 + Math.sin(Date.now() / 80) * 0.2);
ctx.strokeStyle = `rgba(255,138,80,${0.5})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px + CELL_PX / 2, py + CELL_PX / 2, pulseSize / 2 + 4, 0, Math.PI * 2);
ctx.stroke();
}
}
// Draw players
for (let i = 0; i < 4; i++) {
const p = players[i];
const px = p.x * CELL_PX + CELL_PX / 2;
const py = p.y * CELL_PX + CELL_PX / 2;
const radius = CELL_PX / 2.5;
// Player body
ctx.fillStyle = trapped[i] ? '#666' : PLAYER_COLORS[i];
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fill();
// Trapped overlay
if (trapped[i]) {
ctx.strokeStyle = '#ff4d4d';
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(px, py, radius + 2, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Trapped X
ctx.strokeStyle = '#ff4d4d';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px - 4, py - 4);
ctx.lineTo(px + 4, py + 4);
ctx.moveTo(px + 4, py - 4);
ctx.lineTo(px - 4, py + 4);
ctx.stroke();
}
// Cleanser active indicator
if (cleanserActive[i]) {
ctx.strokeStyle = '#66e6ff';
ctx.lineWidth = 2;
ctx.setLineDash([4, 2]);
ctx.beginPath();
ctx.arc(px, py, radius + 8, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// Player name
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(PLAYER_NAMES[i], px, py - radius - 6);
// Score
ctx.fillStyle = '#61ff54';
ctx.font = '8px sans-serif';
ctx.fillText(Math.round(scores[i]), px, py + radius + 10);
}
// Draw bot paths (debug toggle with D)
if (showPaths) {
const pathColors = ['#33cc3355', '#4d80ff55', '#ff993355', '#cc66ff55'];
for (let i = 0; i < 4; i++) {
const path = botPaths[i];
if (path.length === 0) continue;
ctx.strokeStyle = pathColors[i];
ctx.lineWidth = 3;
ctx.setLineDash([4, 4]);
ctx.beginPath();
const sx = players[i].x * CELL_PX + CELL_PX / 2;
const sy = players[i].y * CELL_PX + CELL_PX / 2;
ctx.moveTo(sx, sy);
for (const node of path) {
ctx.lineTo(node.x * CELL_PX + CELL_PX / 2, node.y * CELL_PX + CELL_PX / 2);
}
ctx.stroke();
ctx.setLineDash([]);
}
}
// Draw NPC center marker
const npcPx = NPC_CENTER.x * CELL_PX + CELL_PX / 2;
const npcPy = NPC_CENTER.y * CELL_PX + CELL_PX / 2;
ctx.fillStyle = '#cc3380';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('PUMP', npcPx, npcPy - 6);
ctx.font = '10px sans-serif';
ctx.fillText('NPC', npcPx, npcPy + 8);
}
// ── UI Updates ──
function formatTime(t) {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function updateUI() {
document.getElementById('timer').textContent = formatTime(180 - elapsed);
const phaseEl = document.getElementById('phase-label');
phaseEl.textContent = PHASE_NAMES[currentPhase];
phaseEl.className = 'value ' + PHASE_CLASSES[currentPhase];
const stickyCount = grid.flat().filter(c => c === STICKY).length;
const coverage = Math.round((stickyCount / TOTAL_PLAYABLE) * 100);
document.getElementById('coverage').textContent = coverage + '%';
document.getElementById('tick-count').textContent = totalTicks;
document.getElementById('bubble-count').textContent = bubblesSpawned;
// Player list
const listEl = document.getElementById('player-list');
listEl.innerHTML = players.map((p, i) => `
<div class="player-entry" style="opacity:${trapped[i] ? 0.5 : 1}">
<div class="player-dot" style="background:${PLAYER_COLORS[i]}"></div>
<span class="player-name">${PLAYER_NAMES[i]}</span>
<span class="player-score">${Math.round(scores[i])}</span>
<span class="player-missions">×${missions[i]}</span>
</div>
`).join('');
}
// ── Input ──
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'r': case 'R': resetGame(); break;
case 'p': case 'P': paused = !paused; break;
case 'd': case 'D': showPaths = !showPaths; break;
}
});
// ── Game Loop ──
let lastTime = 0;
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
if (!paused && elapsed < 180) {
elapsed += dt;
// Growth tick
growthTimer += dt;
if (growthTimer >= 3.0) {
growthTimer -= 3.0;
processGrowthTick();
trySpawnBubble();
}
// Bubbles
processBubbles(dt);
// Player movement
updatePlayerMovement(dt);
// Phase
updatePhase();
// Cooldowns
updateCooldowns(dt);
// Bot AI — all non-selected players auto-pathfind
updateBots(dt);
// Score decay for trapped players
for (let i = 0; i < 4; i++) {
if (trapped[i]) {
scores[i] = Math.max(0, scores[i] - dt * 2);
}
}
// Passive score for surviving
for (let i = 0; i < 4; i++) {
if (!trapped[i]) {
scores[i] += dt * 1;
}
}
}
draw();
if (Math.floor(elapsed * 4) % 2 === 0) updateUI(); // throttle UI updates
requestAnimationFrame(gameLoop);
}
// ── Start ──
resetGame();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>