/**
* 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 };
})();