/** * game.js * Main game controller: initialisation, game loop, input handling, * automatic player interactions, and all canvas rendering. * Supports 1-player and 2-player (mirrored split-screen) modes. */ const Game = (() => { /* ── Private state ──────────────────────────────────────────── */ let canvas, ctx; let running, lastTimestamp, animFrame; let timeLeft; let playerCount = 1; // Per-player arrays (index 0 = P1, index 1 = P2) let gPlayers = []; // Player instances let gLeftBelts = []; // LeftConveyorSystem instances let gRightBelts = []; // RightConveyorSystem instances let gLabs = []; // Lab instances let gScores = []; // numbers let gPopups = []; // score-popup arrays let gHappyTimers = []; // patient happy timers let gNames = []; // player name strings /* ── Active render-state proxy ──────────────────────────────── */ // Set _pIdx before each render pass to pick the right player's data. let _pIdx = 0; let _mirrored = false; const RS = { get player() { return gPlayers[_pIdx]; }, get leftBelts() { return gLeftBelts[_pIdx]; }, get rightBelt() { return gRightBelts[_pIdx]; }, get lab() { return gLabs[_pIdx]; }, get score() { return gScores[_pIdx]; }, get popups() { return gPopups[_pIdx]; }, get happyTimer() { return gHappyTimers[_pIdx]; }, get name() { return gNames[_pIdx]; }, }; /** * ft — fillText that reads correctly even inside a mirrored ctx transform. * When _mirrored is true the outer ctx has scale(-1,1) applied; we * double-flip locally so the glyph is drawn right-way-round at the * correct logical position. */ function ft(text, x, y) { if (!_mirrored) { ctx.fillText(text, x, y); return; } ctx.save(); ctx.translate(x, y); ctx.scale(-1, 1); ctx.fillText(text, 0, 0); ctx.restore(); } /* ── Input ──────────────────────────────────────────────────── */ let keys = {}; let keyHandlerDown, keyHandlerUp; function onKeyDown(e) { if (!running) return; if (keys[e.code]) return; // suppress key-repeat for movement keys[e.code] = true; // P1: arrow keys (always) switch (e.code) { case 'ArrowUp': e.preventDefault(); gPlayers[0].move('up'); break; case 'ArrowDown': e.preventDefault(); gPlayers[0].move('down'); break; case 'ArrowLeft': e.preventDefault(); gPlayers[0].move('left'); break; case 'ArrowRight': e.preventDefault(); gPlayers[0].move('right'); break; } if (playerCount === 1) { // 1P: WASD also drives P1 switch (e.code) { case 'KeyW': e.preventDefault(); gPlayers[0].move('up'); break; case 'KeyS': e.preventDefault(); gPlayers[0].move('down'); break; case 'KeyA': e.preventDefault(); gPlayers[0].move('left'); break; case 'KeyD': e.preventDefault(); gPlayers[0].move('right'); break; } } else { // 2P: WASD drives P2. Left/right are swapped because P2's side is // mirrored — pressing visual-right (D) means moving left in game coords. switch (e.code) { case 'KeyW': e.preventDefault(); gPlayers[1].move('up'); break; case 'KeyS': e.preventDefault(); gPlayers[1].move('down'); break; case 'KeyA': e.preventDefault(); gPlayers[1].move('right'); break; case 'KeyD': e.preventDefault(); gPlayers[1].move('left'); break; } } } function onKeyUp(e) { keys[e.code] = false; } /* ── Scoring helpers (per-player index) ─────────────────────── */ function addScoreFor(pIdx, delta, px, py) { gScores[pIdx] += delta; const sign = delta >= 0 ? '+' : ''; const color = delta >= 0 ? '#ffdd44' : '#ff4455'; gPopups[pIdx].push({ x: px, y: py, text: sign + delta, life: 900, color }); } function dropGoodFor(pIdx, good) { const penalty = good.isSpecial ? SETTINGS.POINTS_DROP_SPECIAL : SETTINGS.POINTS_DROP_GOOD; addScoreFor(pIdx, penalty, SETTINGS.LEFT_ZONE_END - 20, SETTINGS.BELT_ROWS[good.beltRow]); } /* ── Auto-interaction (position-based, per player) ──────────── */ function handleInteractionsFor(pIdx) { const p = gPlayers[pIdx]; const lb = gLeftBelts[pIdx]; const rb = gRightBelts[pIdx]; const l = gLabs[pIdx]; const { col, row } = p; // Pickup from left belt (col 0) if (col === 0 && lb.hasReadyGood(row)) { const good = lb.tryCollect(row); if (good) { if (!p.addGood(good)) dropGoodFor(pIdx, good); } } // Right-zone interactions (rightmost col) if (col === SETTINGS.PLAYER_COLS - 1) { if (row === 2 && p.goods.length > 0) { const goods = p.depositGoods(); addScoreFor(pIdx, goods.length * SETTINGS.POINTS_GOOD_LAB, p.pixelX(), p.pixelY() - 30); l.depositGoods(goods, () => rb.addMedication()); } if (row === 1 && !p.hasMedication && rb.hasReadyMedication()) { rb.tryPickup(); p.pickupMedication(); } if (row === 0 && p.hasMedication) { if (p.deliverMedication()) { addScoreFor(pIdx, SETTINGS.POINTS_SPECIAL_PATIENT, p.pixelX(), p.pixelY() - 30); gHappyTimers[pIdx] = SETTINGS.PATIENT_HAPPY_DURATION; } } } } /* ── Update ─────────────────────────────────────────────────── */ function update(dt) { for (let i = 0; i < playerCount; i++) { gPlayers[i].update(dt); gLeftBelts[i].update(dt, (g) => dropGoodFor(i, g)); gRightBelts[i].update(dt); gLabs[i].update(dt); handleInteractionsFor(i); gPopups[i].forEach(p => { p.life -= dt; p.y -= dt * 0.04; }); gPopups[i] = gPopups[i].filter(p => p.life > 0); if (gHappyTimers[i] > 0) gHappyTimers[i] -= dt; } timeLeft -= dt; if (timeLeft <= 0) { timeLeft = 0; endGame(); } } /* ── End game ───────────────────────────────────────────────── */ function endGame() { running = false; cancelAnimationFrame(animFrame); document.removeEventListener('keydown', keyHandlerDown); document.removeEventListener('keyup', keyHandlerUp); HighScoreScreen.show(gNames, gScores, playerCount); } /* ════════════════════════════ RENDERING ════════════════════════ */ /* ── Utility draw helpers ───────────────────────────────────── */ function roundRect(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } function drawBeltStripes(animOff, x, y, w, h, colorA, colorB, leftToRight) { ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); const stripeW = 20; const off = leftToRight ? animOff % stripeW : (stripeW - (animOff % stripeW)) % stripeW; ctx.fillStyle = colorA; ctx.fillRect(x, y, w, h); ctx.fillStyle = colorB; for (let sx = x - stripeW * 2 + off; sx < x + w + stripeW; sx += stripeW * 2) { ctx.beginPath(); ctx.moveTo(sx, y); ctx.lineTo(sx + stripeW, y); ctx.lineTo(sx + stripeW - 6, y + h); ctx.lineTo(sx - 6, y + h); ctx.fill(); } ctx.restore(); } function drawGood(good) { const s = SETTINGS.GOOD_SIZE; const gx = good.x; const gy = good.y - SETTINGS.BELT_HEIGHT / 2 - s / 2; drawKidney(gx, gy, s); } function drawKidney(cx, cy, size) { const w = size * 0.95; const h = size * 0.72; const hw = w / 2; const hh = h / 2; ctx.save(); ctx.beginPath(); ctx.moveTo(cx - hw * 0.1, cy - hh); ctx.bezierCurveTo(cx + hw * 0.6, cy - hh * 1.1, cx + hw, cy - hh * 0.3, cx + hw, cy + hh * 0.1); ctx.bezierCurveTo(cx + hw, cy + hh * 1.1, cx - hw * 0.1, cy + hh, cx - hw * 0.5, cy + hh * 0.6); ctx.bezierCurveTo(cx - hw * 1.05, cy + hh * 0.2, cx - hw * 0.9, cy - hh * 0.35, cx - hw * 0.45, cy - hh * 0.1); ctx.bezierCurveTo(cx - hw * 0.15, cy - hh * 0.4, cx - hw * 0.4, cy - hh, cx - hw * 0.1, cy - hh); ctx.closePath(); ctx.shadowColor = 'rgba(0,0,0,0.4)'; ctx.shadowBlur = 4; ctx.shadowOffsetY = 2; ctx.fillStyle = '#c0392b'; ctx.fill(); ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; ctx.strokeStyle = '#7b1a12'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + hw * 0.05, cy - hh * 0.65); ctx.bezierCurveTo(cx + hw * 0.55, cy - hh * 0.75, cx + hw * 0.75, cy - hh * 0.15, cx + hw * 0.6, cy + hh * 0.1); ctx.strokeStyle = 'rgba(255,160,140,0.55)'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx - hw * 0.05, cy + hh * 0.05, hw * 0.22, hh * 0.28, -0.3, 0, Math.PI * 2); ctx.fillStyle = '#e8b4a8'; ctx.fill(); ctx.strokeStyle = '#b5483a'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } function drawMedication(med) { const s = SETTINGS.GOOD_SIZE; const hs = s / 2; const mx = med.x; const my = med.y - SETTINGS.BELT_HEIGHT / 2 - hs; ctx.shadowColor = SETTINGS.COLOR_MED; ctx.shadowBlur = 14; roundRect(ctx, mx - hs + 2, my - hs + 3, s - 4, s - 3, 6); ctx.fillStyle = SETTINGS.COLOR_MED; ctx.fill(); ctx.strokeStyle = SETTINGS.COLOR_MED_OUT; ctx.lineWidth = 2; ctx.stroke(); roundRect(ctx, mx - 5, my - hs - 2, 10, 7, 3); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = SETTINGS.COLOR_MED_OUT; ctx.lineWidth = 1.5; ctx.stroke(); ctx.strokeStyle = 'rgba(0,80,40,0.8)'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(mx, my - 2); ctx.lineTo(mx, my + 6); ctx.moveTo(mx - 4, my + 2); ctx.lineTo(mx + 4, my + 2); ctx.stroke(); ctx.shadowBlur = 0; ctx.lineCap = 'butt'; } /* ── Header — 1P ────────────────────────────────────────────── */ function renderHeader() { const W = SETTINGS.CANVAS_WIDTH; const H = SETTINGS.HEADER_HEIGHT; ctx.fillStyle = SETTINGS.COLOR_HEADER; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = SETTINGS.COLOR_HEADER_LINE; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(W, H); ctx.stroke(); // Score ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('SCORE', 24, H / 2 - 8); ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillText(Math.max(0, RS.score).toString().padStart(5, '0'), 24, H / 2 + 10); // Player name ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.fillText('PLAYER', W / 2, H / 2 - 8); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 18px "Courier New", monospace'; ctx.fillText(RS.name.toUpperCase().slice(0, 12), W / 2, H / 2 + 10); // Timer const secLeft = Math.ceil(timeLeft / 1000); const timerStr = `${Math.floor(secLeft / 60)}:${(secLeft % 60).toString().padStart(2, '0')}`; const tColor = secLeft > 60 ? SETTINGS.COLOR_TIMER_OK : secLeft > 20 ? SETTINGS.COLOR_TIMER_WARN : SETTINGS.COLOR_TIMER_DANGER; ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'right'; ctx.fillText('TIME', W - 24, H / 2 - 8); ctx.fillStyle = tColor; ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillText(timerStr, W - 24, H / 2 + 10); } /* ── Header — 2P (full-width, both players) ─────────────────── */ function renderHeader2P() { const W = SETTINGS.CANVAS_WIDTH; const TW = W * 2; const H = SETTINGS.HEADER_HEIGHT; ctx.fillStyle = SETTINGS.COLOR_HEADER; ctx.fillRect(0, 0, TW, H); ctx.strokeStyle = SETTINGS.COLOR_HEADER_LINE; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(TW, H); ctx.stroke(); // Centre divider line ctx.strokeStyle = 'rgba(80,120,180,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(W, 0); ctx.lineTo(W, H); ctx.stroke(); const secLeft = Math.ceil(timeLeft / 1000); const timerStr = `${Math.floor(secLeft / 60)}:${(secLeft % 60).toString().padStart(2, '0')}`; const tColor = secLeft > 60 ? SETTINGS.COLOR_TIMER_OK : secLeft > 20 ? SETTINGS.COLOR_TIMER_WARN : SETTINGS.COLOR_TIMER_DANGER; // Shared timer in the centre ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('TIME', TW / 2, H / 2 - 8); ctx.fillStyle = tColor; ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillText(timerStr, TW / 2, H / 2 + 10); // P2 — left half ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'left'; ctx.fillText('SCORE', 24, H / 2 - 8); ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillText(Math.max(0, gScores[1]).toString().padStart(5, '0'), 24, H / 2 + 10); ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.fillText('P2', W / 2, H / 2 - 8); ctx.fillStyle = '#aaddff'; ctx.font = 'bold 18px "Courier New", monospace'; ctx.fillText(gNames[1].toUpperCase().slice(0, 12), W / 2, H / 2 + 10); // P1 — right half ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'right'; ctx.fillText('SCORE', TW - 24, H / 2 - 8); ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; ctx.font = 'bold 22px "Courier New", monospace'; ctx.fillText(Math.max(0, gScores[0]).toString().padStart(5, '0'), TW - 24, H / 2 + 10); ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '13px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.fillText('P1', W + W / 2, H / 2 - 8); ctx.fillStyle = '#aaddff'; ctx.font = 'bold 18px "Courier New", monospace'; ctx.fillText(gNames[0].toUpperCase().slice(0, 12), W + W / 2, H / 2 + 10); } /* ── Left zone ──────────────────────────────────────────────── */ function renderLeftZone() { const top = SETTINGS.HEADER_HEIGHT; const bot = SETTINGS.CANVAS_HEIGHT; const zoneW = SETTINGS.LEFT_ZONE_END; const beltH = SETTINGS.BELT_HEIGHT; ctx.fillStyle = SETTINGS.COLOR_BG; ctx.fillRect(0, top, zoneW, bot - top); ctx.fillStyle = 'rgba(40,80,120,0.35)'; ctx.font = 'bold 11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft('SUPPLY', zoneW / 2, top + 6); SETTINGS.BELT_ROWS.forEach((cy, row) => { const by = cy - beltH / 2; drawBeltStripes(RS.leftBelts.animOffset, 0, by, zoneW, beltH, SETTINGS.COLOR_BELT, SETTINGS.COLOR_BELT_STRIPE, true); ctx.fillStyle = SETTINGS.COLOR_BELT_EDGE; ctx.fillRect(0, by - 4, zoneW, 4); ctx.fillRect(0, by + beltH, zoneW, 4); ctx.fillStyle = 'rgba(136,200,255,0.4)'; ctx.font = '10px "Courier New", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ft(`B${row + 1}`, 6, cy); if (RS.leftBelts.hasReadyGood(row)) { ctx.fillStyle = '#ffdd44'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'right'; ft('►', zoneW - 4, cy); } }); RS.leftBelts.allGoods().forEach(good => { if (good.state === GoodState.SLIDING || good.state === GoodState.READY) { drawGood(good); } }); } /* ── Middle zone ────────────────────────────────────────────── */ function renderMiddleZone() { const zx = SETTINGS.MIDDLE_ZONE_START; const zw = SETTINGS.MIDDLE_ZONE_END - SETTINGS.MIDDLE_ZONE_START; const top = SETTINGS.HEADER_HEIGHT; const bot = SETTINGS.CANVAS_HEIGHT; ctx.fillStyle = '#0c1e34'; ctx.fillRect(zx, top, zw, bot - top); ctx.fillStyle = 'rgba(40,80,120,0.35)'; ctx.font = 'bold 11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft('COLLECTION', zx + zw / 2, top + 6); ctx.strokeStyle = 'rgba(30,60,100,0.5)'; ctx.lineWidth = 1; SETTINGS.BELT_ROWS.forEach(cy => { ctx.beginPath(); ctx.setLineDash([4, 6]); ctx.moveTo(zx, cy); ctx.lineTo(zx + zw, cy); ctx.stroke(); }); ctx.setLineDash([]); } /* ── Player ─────────────────────────────────────────────────── */ function renderPlayer() { const px = RS.player.pixelX(); const py = RS.player.pixelY(); const isWalking = RS.player.moveCooldown > 0; const phase = RS.player.walkPhase; const legSwing = isWalking ? Math.sin(phase) * 12 : 0; const armSwing = isWalking ? -Math.sin(phase) * 10 : 0; const bob = isWalking ? Math.abs(Math.sin(phase)) * 2 : 0; const flash = RS.player.interactFlash > 0; const groundY = py + 16; const coatHem = groundY - 6; const waistY = groundY - 17; const torsoTop = waistY - 22 - bob; const headCY = torsoTop - 14; const headR = 11; const skinColor = '#f5c99a'; const coatColor = flash ? '#ddf4ff' : '#f0f4ff'; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // Shadow ctx.globalAlpha = 0.25; ctx.fillStyle = '#000'; ctx.beginPath(); ctx.ellipse(px, groundY + 5, 17, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; // Trousers ctx.lineWidth = 9; ctx.strokeStyle = '#1e2d4a'; ctx.beginPath(); ctx.moveTo(px - 5, waistY); ctx.lineTo(px - 5 + legSwing, coatHem + 1); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px + 5, waistY); ctx.lineTo(px + 5 - legSwing, coatHem + 1); ctx.stroke(); // Shoes ctx.fillStyle = '#111'; ctx.beginPath(); ctx.ellipse(px - 5 + legSwing + 2, groundY, 9, 5, 0.08, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(px + 5 - legSwing - 2, groundY, 9, 5, -0.08, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.beginPath(); ctx.ellipse(px - 5 + legSwing, groundY - 2, 5, 2, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(px + 5 - legSwing, groundY - 2, 5, 2, 0, 0, Math.PI * 2); ctx.fill(); // White coat body roundRect(ctx, px - 13, torsoTop, 26, coatHem - torsoTop, 5); ctx.fillStyle = coatColor; ctx.fill(); ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1.5; ctx.stroke(); // Lapels / scrubs ctx.fillStyle = '#7ab8d4'; ctx.beginPath(); ctx.moveTo(px - 6, torsoTop + 2); ctx.lineTo(px, torsoTop + 14); ctx.lineTo(px + 6, torsoTop + 2); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(px - 13, torsoTop + 4); ctx.lineTo(px - 3, torsoTop + 16); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px + 13, torsoTop + 4); ctx.lineTo(px + 3, torsoTop + 16); ctx.stroke(); // Button line ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.moveTo(px, torsoTop + 16); ctx.lineTo(px, coatHem - 2); ctx.stroke(); ctx.setLineDash([]); // Name badge roundRect(ctx, px - 12, torsoTop + 18, 14, 9, 2); ctx.fillStyle = 'white'; ctx.fill(); ctx.strokeStyle = '#aabbcc'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#336699'; ctx.font = 'bold 5px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ft('DR.', px - 11, torsoTop + 22.5); // Red cross ctx.fillStyle = '#cc2222'; ctx.fillRect(px + 6, torsoTop + 19, 6, 2); ctx.fillRect(px + 8, torsoTop + 17, 2, 6); // Left sleeve ctx.strokeStyle = coatColor; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(px - 12, torsoTop + 5); ctx.lineTo(px - 16 + armSwing, torsoTop + 20); ctx.stroke(); ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px - 12, torsoTop + 5); ctx.lineTo(px - 16 + armSwing, torsoTop + 20); ctx.stroke(); // Right sleeve ctx.strokeStyle = coatColor; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(px + 12, torsoTop + 5); ctx.lineTo(px + 16 - armSwing, torsoTop + 20); ctx.stroke(); ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px + 12, torsoTop + 5); ctx.lineTo(px + 16 - armSwing, torsoTop + 20); ctx.stroke(); // Forearms ctx.strokeStyle = skinColor; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(px - 16 + armSwing, torsoTop + 20); ctx.lineTo(px - 17 + armSwing, torsoTop + 28); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px + 16 - armSwing, torsoTop + 20); ctx.lineTo(px + 17 - armSwing, torsoTop + 28); ctx.stroke(); // Hands ctx.fillStyle = skinColor; ctx.beginPath(); ctx.arc(px - 17 + armSwing, torsoTop + 31, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(px + 17 - armSwing, torsoTop + 31, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; ctx.stroke(); // Stethoscope ctx.fillStyle = '#aaaaaa'; ctx.beginPath(); ctx.arc(px - 7, torsoTop + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(px + 7, torsoTop + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#888'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(px - 7, torsoTop + 6); ctx.bezierCurveTo(px - 7, torsoTop + 26, px + 7, torsoTop + 26, px + 7, torsoTop + 6); ctx.stroke(); ctx.fillStyle = '#bbb'; ctx.strokeStyle = '#888'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(px, torsoTop + 26, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#ddd'; ctx.beginPath(); ctx.arc(px, torsoTop + 26, 2, 0, Math.PI * 2); ctx.fill(); // Neck ctx.fillStyle = skinColor; ctx.beginPath(); ctx.rect(px - 4, headCY + headR - 4, 8, 8); ctx.fill(); // Head ctx.beginPath(); ctx.arc(px, headCY, headR, 0, Math.PI * 2); ctx.fillStyle = skinColor; ctx.fill(); ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; ctx.stroke(); // Hair ctx.fillStyle = '#3d2200'; ctx.beginPath(); ctx.arc(px, headCY, headR, Math.PI, 0); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.arc(px - headR + 1, headCY, headR * 0.5, Math.PI * 0.6, Math.PI * 1.3); ctx.fill(); // Ears ctx.fillStyle = skinColor; ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(px - headR, headCY + 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.ellipse(px + headR, headCY + 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Glasses ctx.strokeStyle = '#445566'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.rect(px - 10, headCY, 8, 6); ctx.stroke(); ctx.beginPath(); ctx.rect(px + 2, headCY, 8, 6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px - 2, headCY + 3); ctx.lineTo(px + 2, headCY + 3); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px - 10, headCY + 3); ctx.lineTo(px - headR - 1, headCY + 3); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px + 10, headCY + 3); ctx.lineTo(px + headR + 1, headCY + 3); ctx.stroke(); // Eyes ctx.fillStyle = '#224466'; ctx.beginPath(); ctx.arc(px - 6, headCY + 3, 2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(px + 6, headCY + 3, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.beginPath(); ctx.arc(px - 5, headCY + 2, 0.9, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(px + 7, headCY + 2, 0.9, 0, Math.PI * 2); ctx.fill(); // Nose ctx.fillStyle = '#d4956a'; ctx.beginPath(); ctx.arc(px, headCY + 7, 2, 0, Math.PI * 2); ctx.fill(); // Smile ctx.strokeStyle = '#a06040'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(px, headCY + 10, 4, 0.15, Math.PI - 0.15); ctx.stroke(); ctx.restore(); // Carried kidneys — small icons above head if (RS.player.goods.length > 0) { const iconSize = 11; const gap = 14; const totalW = (RS.player.goods.length - 1) * gap; const sx = px - totalW / 2; const iy = headCY - headR - 10; RS.player.goods.forEach((_, i) => drawKidney(sx + i * gap, iy, iconSize)); } // Medication icon above head if (RS.player.hasMedication) { const iy = headCY - headR - (RS.player.goods.length > 0 ? 24 : 10); ctx.shadowColor = SETTINGS.COLOR_MED; ctx.shadowBlur = 10; ctx.font = '14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ft('💊', px, iy); ctx.shadowBlur = 0; } } /* ── Right zone ─────────────────────────────────────────────── */ function renderRightZone() { const zx = SETTINGS.RIGHT_ZONE_START; const zw = SETTINGS.RIGHT_ZONE_END - SETTINGS.RIGHT_ZONE_START; const top = SETTINGS.HEADER_HEIGHT; const bot = SETTINGS.CANVAS_HEIGHT; const cx = zx + zw / 2; ctx.fillStyle = '#0b1c30'; ctx.fillRect(zx, top, zw, bot - top); ctx.fillStyle = 'rgba(40,80,120,0.35)'; ctx.font = 'bold 11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft('HOSPITAL', cx, top + 6); // ── Patient ────────────────────────────────────────────────── const py0 = SETTINGS.BELT_ROWS[0]; const happy = RS.happyTimer > 0; roundRect(ctx, zx + 20, py0 - 50, zw - 40, 80, 10); ctx.fillStyle = happy ? 'rgba(80,60,10,0.4)' : 'rgba(60,20,10,0.4)'; ctx.fill(); ctx.strokeStyle = happy ? SETTINGS.COLOR_PATIENT_HAPPY : SETTINGS.COLOR_PATIENT_DARK; ctx.lineWidth = 2; ctx.stroke(); const pColor = happy ? SETTINGS.COLOR_PATIENT_HAPPY : SETTINGS.COLOR_PATIENT; roundRect(ctx, cx - 14, py0 - 20, 28, 26, 5); ctx.fillStyle = '#eeeeee'; ctx.fill(); ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(cx, py0 - 28, 16, 0, Math.PI * 2); ctx.fillStyle = pColor; ctx.fill(); ctx.strokeStyle = SETTINGS.COLOR_PATIENT_DARK; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = '#222'; if (happy) { ctx.beginPath(); ctx.arc(cx, py0 - 30, 7, 0, Math.PI); ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke(); ctx.beginPath(); ctx.arc(cx - 5, py0 - 32, 2, 0, Math.PI * 2); ctx.arc(cx + 5, py0 - 32, 2, 0, Math.PI * 2); ctx.fill(); } else { ctx.beginPath(); ctx.arc(cx, py0 - 22, 5, Math.PI, 0); ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = '#ff6644'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ft('~', cx - 18, py0 - 32); ft('~', cx + 18, py0 - 32); } ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft('PATIENT', cx, py0 + 32); // ── Right belt ─────────────────────────────────────────────── const py1 = SETTINGS.BELT_ROWS[1]; const beltH = SETTINGS.BELT_HEIGHT; const by = py1 - beltH / 2; drawBeltStripes(RS.rightBelt.animOffset, zx, by, zw, beltH, SETTINGS.COLOR_BELT_R, SETTINGS.COLOR_BELT_STRIPE_R, false); ctx.fillStyle = SETTINGS.COLOR_BELT_EDGE; ctx.fillRect(zx, by - 4, zw, 4); ctx.fillRect(zx, by + beltH, zw, 4); ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft('◄ MEDICATION BELT', cx, by + beltH + 8); RS.rightBelt.visibleMedications().forEach(drawMedication); // ── Laboratory ─────────────────────────────────────────────── const py2 = SETTINGS.BELT_ROWS[2]; const labW = zw - 30; const labH = 70; const lx = zx + 15; const ly = py2 - labH / 2; const flash = RS.lab.isFlashing(); const labColor = flash ? 'rgba(10,100,50,0.7)' : SETTINGS.COLOR_LAB; roundRect(ctx, lx, ly, labW, labH, 10); ctx.fillStyle = labColor; ctx.fill(); ctx.strokeStyle = flash ? SETTINGS.COLOR_LAB_OUTLINE : SETTINGS.COLOR_LAB_LIGHT; ctx.lineWidth = flash ? 3 : 2; ctx.stroke(); ctx.fillStyle = '#aaffcc'; ctx.font = 'bold 13px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ft('LABORATORY', cx, ly + 18); if (RS.lab.isAnalyzing()) { const prog = RS.lab.analyzeProgress(); const bw = labW - 20; const bx = lx + 10; const bby = ly + 36; ctx.fillStyle = 'rgba(0,0,0,0.5)'; roundRect(ctx, bx, bby, bw, 12, 4); ctx.fill(); ctx.fillStyle = SETTINGS.COLOR_MED; roundRect(ctx, bx, bby, bw * prog, 12, 4); ctx.fill(); ctx.fillStyle = '#aaffcc'; ctx.font = '10px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ft('ANALYSING...', cx, bby + 6); ctx.textBaseline = 'top'; ft(`Kidneys: ${RS.lab.totalProcessed}`, cx, ly + 52); } else { ctx.fillStyle = '#aaffcc'; ctx.font = '11px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ft(`Processed: ${RS.lab.totalProcessed}`, cx, ly + 36); } } /* ── Score popups ───────────────────────────────────────────── */ function renderPopups() { RS.popups.forEach(p => { const alpha = Math.min(1, p.life / 300); ctx.globalAlpha = alpha; ctx.fillStyle = p.color; ctx.font = 'bold 18px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ft(p.text, p.x, p.y); ctx.globalAlpha = 1; }); } /* ── HUD: carry status bar ──────────────────────────────────── */ function renderHUD() { const bx = SETTINGS.MIDDLE_ZONE_START + 8; const by = SETTINGS.CANVAS_HEIGHT - 28; const dotR = 7; const gap = 18; ctx.fillStyle = SETTINGS.COLOR_TEXT; ctx.font = '11px "Courier New", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ft('CARRY:', bx, by); for (let i = 0; i < SETTINGS.MAX_CARRY; i++) { const dx = bx + 60 + i * gap; const filled = i < RS.player.goods.length; ctx.beginPath(); ctx.arc(dx, by, dotR, 0, Math.PI * 2); ctx.fillStyle = filled ? SETTINGS.COLOR_GOOD : 'rgba(40,80,120,0.5)'; ctx.fill(); ctx.strokeStyle = filled ? SETTINGS.COLOR_GOOD_OUTLINE : 'rgba(60,120,180,0.4)'; ctx.lineWidth = 1.5; ctx.stroke(); } if (RS.player.hasMedication) { ctx.fillStyle = SETTINGS.COLOR_MED; ctx.font = '11px "Courier New", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ft('💊 CARRYING MED', bx + 170, by); } } /* ── One side render (zones + player + HUD + popups) ────────── */ function renderOneSide() { renderLeftZone(); renderMiddleZone(); renderRightZone(); renderPlayer(); renderPopups(); renderHUD(); } /* ── Master render ──────────────────────────────────────────── */ function render() { const W = SETTINGS.CANVAS_WIDTH; const totalW = playerCount === 2 ? W * 2 : W; ctx.clearRect(0, 0, totalW, SETTINGS.CANVAS_HEIGHT); ctx.fillStyle = SETTINGS.COLOR_BG; ctx.fillRect(0, 0, totalW, SETTINGS.CANVAS_HEIGHT); if (playerCount === 2) { // ── P2 on left half (mirrored) ────────────────────────────── _pIdx = 1; _mirrored = true; ctx.save(); ctx.translate(W, 0); ctx.scale(-1, 1); renderOneSide(); ctx.restore(); _mirrored = false; // ── P1 on right half ───────────────────────────────────────── _pIdx = 0; ctx.save(); ctx.translate(W, 0); renderOneSide(); ctx.restore(); renderHeader2P(); } else { _pIdx = 0; renderOneSide(); renderHeader(); } } /* ── Game loop ──────────────────────────────────────────────── */ function loop(timestamp) { if (!running) return; const dt = Math.min(timestamp - lastTimestamp, 80); lastTimestamp = timestamp; update(dt); render(); animFrame = requestAnimationFrame(loop); } /* ── Public init ────────────────────────────────────────────── */ function init(name, name2) { playerCount = name2 ? 2 : 1; gNames = name2 ? [name, name2] : [name]; gScores = Array(playerCount).fill(0); gPopups = Array.from({ length: playerCount }, () => []); gHappyTimers = Array(playerCount).fill(0); timeLeft = SETTINGS.GAME_DURATION * 1000; gPlayers = Array.from({ length: playerCount }, () => new Player()); gLeftBelts = Array.from({ length: playerCount }, () => new LeftConveyorSystem()); gRightBelts = Array.from({ length: playerCount }, () => new RightConveyorSystem()); gLabs = Array.from({ length: playerCount }, () => new Lab()); keys = {}; const W = SETTINGS.CANVAS_WIDTH; const gameScreen = document.getElementById('game-screen'); gameScreen.innerHTML = ''; canvas = document.createElement('canvas'); canvas.width = playerCount === 2 ? W * 2 : W; canvas.height = SETTINGS.CANVAS_HEIGHT; canvas.id = 'game-canvas'; canvas.setAttribute('tabindex', '0'); // Scale down to fit viewport in 2P mode if (playerCount === 2) { canvas.style.maxWidth = '100%'; canvas.style.height = 'auto'; } gameScreen.appendChild(canvas); ctx = canvas.getContext('2d'); const legend = document.createElement('div'); legend.className = 'controls-legend'; legend.innerHTML = playerCount === 2 ? 'P1 (right side): ↑ ↓ ← →' + 'P2 (left side): W S A D' : 'Move: ↑↓←→ or W A S D' + 'Collect at left · Deposit / Pickup / Deliver at right'; gameScreen.appendChild(legend); keyHandlerDown = onKeyDown; keyHandlerUp = onKeyUp; document.addEventListener('keydown', keyHandlerDown); document.addEventListener('keyup', keyHandlerUp); // ── Mobile D-pad controls ─────────────────────────────────── buildDPads(gameScreen); running = true; lastTimestamp = performance.now(); animFrame = requestAnimationFrame(loop); canvas.focus(); } /* ── Joystick builder ──────────────────────────────────────── */ function buildDPads(container) { if (playerCount === 2) { // P2 on left (mirrored screen → left/right swapped), P1 on right container.appendChild(makeJoystick('P2', 'joy-left', 1, true)); container.appendChild(makeJoystick('P1', 'joy-right', 0, false)); } else { container.appendChild(makeJoystick('', 'joy-right', 0, false)); } } function makeJoystick(label, sideClass, pIdx, swapLR) { const wrap = document.createElement('div'); wrap.className = `joystick-wrap ${sideClass}`; if (label) { const lbl = document.createElement('div'); lbl.className = 'joystick-label'; lbl.textContent = label; wrap.appendChild(lbl); } const base = document.createElement('div'); base.className = 'joystick-base'; const knob = document.createElement('div'); knob.className = 'joystick-knob'; base.appendChild(knob); wrap.appendChild(base); const MAX_TRAVEL = 34; // px the knob can travel from centre const DEAD_ZONE = 10; // px inner dead zone — no movement triggered const REPEAT_MS = 160; // how often to re-fire move while held let currentDir = null; let repeatTimer = null; function fireMove(dir) { if (running) gPlayers[pIdx].move(dir); } function setDir(dir) { if (dir === currentDir) return; currentDir = dir; clearInterval(repeatTimer); repeatTimer = null; if (dir) { fireMove(dir); // immediate repeatTimer = setInterval(() => fireMove(dir), REPEAT_MS); } } function onTouch(touch) { const rect = base.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dx = touch.clientX - cx; const dy = touch.clientY - cy; const dist = Math.hypot(dx, dy); // Move knob — clamp to MAX_TRAVEL radius const clamped = Math.min(dist, MAX_TRAVEL); const angle = Math.atan2(dy, dx); const kx = Math.cos(angle) * clamped; const ky = Math.sin(angle) * clamped; knob.style.transform = `translate(calc(-50% + ${kx}px), calc(-50% + ${ky}px))`; if (dist < DEAD_ZONE) { setDir(null); return; } // Determine direction from dominant axis let dir; if (Math.abs(dx) >= Math.abs(dy)) { dir = dx > 0 ? (swapLR ? 'left' : 'right') : (swapLR ? 'right' : 'left'); } else { dir = dy > 0 ? 'down' : 'up'; } setDir(dir); } function onEnd() { knob.style.transform = 'translate(-50%, -50%)'; base.classList.remove('active'); setDir(null); } base.addEventListener('touchstart', e => { e.preventDefault(); base.classList.add('active'); onTouch(e.touches[0]); }, { passive: false }); base.addEventListener('touchmove', e => { e.preventDefault(); onTouch(e.touches[0]); }, { passive: false }); base.addEventListener('touchend', onEnd); base.addEventListener('touchcancel', onEnd); return wrap; } return { init }; })();