1081 lines
40 KiB
JavaScript
1081 lines
40 KiB
JavaScript
/**
|
|
* 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
|
|
? '<span>P1 (right side): <kbd>↑ ↓ ← →</kbd></span>' +
|
|
'<span>P2 (left side): <kbd>W</kbd> <kbd>S</kbd> <kbd>A</kbd> <kbd>D</kbd></span>'
|
|
: '<span>Move: <kbd>↑↓←→</kbd> or <kbd>W A S D</kbd></span>' +
|
|
'<span>Collect at left · Deposit / Pickup / Deliver at right</span>';
|
|
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 };
|
|
})();
|