feat: update
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user