zunda_shiwake/src/render/draw.js
2026-06-07 15:05:24 +09:00

526 lines
28 KiB
JavaScript

import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { key, parseKey, cellCenter, mixHex, randomBetween } from '../core/utils.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor } from '../systems/routing.js';
import { truckPayoutMultiplier } from '../systems/economy.js';
const ASSET_PATHS = {
chickMale: './assets/images/chick_male.png',
chickFemale: './assets/images/chick_female.png',
poop: './assets/images/poop.png',
conveyor: './assets/images/conveyor.png',
scannerManual: './assets/images/scanner_manual.png',
scannerAuto: './assets/images/scanner_auto.png',
eggFarm: './assets/images/egg_farm.png',
mixer: './assets/images/mixer.png',
shredder: './assets/images/shredder.png',
truck: './assets/images/truck.png'
};
export const assets = {};
for (const [name, src] of Object.entries(ASSET_PATHS)) {
const img = new Image();
img.src = src;
img.loaded = false;
img.onload = () => { img.loaded = true; };
assets[name] = img;
}
// ART OVERLAY HOOK:
// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS.
// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes.
// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI.
export function drawAll(ctx, canvas, game, helpers) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(ctx, canvas);
ctx.save();
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
ctx.translate(game.view.x + sx, game.view.y + sy);
drawGrid(ctx);
drawConveyors(ctx, game);
drawScannerConnectors(ctx, game);
drawFacilities(ctx, game);
drawEggFarms(ctx, game);
drawScanners(ctx, game);
drawChicks(ctx, game, helpers.activeQueuedChick);
drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle);
drawSelectionBox(ctx, game);
drawEffects(ctx, game);
drawFloatingTexts(ctx, game);
if (game.debug) drawDebug(ctx, game);
ctx.restore();
drawCanvasHints(ctx, game, canvas);
}
function rect(ctx, x, y, w, h, fill = true, stroke = true) {
ctx.beginPath(); ctx.rect(x, y, w, h); if (fill) ctx.fill(); if (stroke) ctx.stroke();
}
function label(ctx, x, y, text, color = THEME.ink) {
ctx.save(); ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore();
}
function drawImageIfLoaded(ctx, image, x, y, w, h) {
if (!image?.loaded) return false;
ctx.drawImage(image, x, y, w, h);
return true;
}
function drawBackground(ctx, canvas) {
ctx.fillStyle = THEME.bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.strokeStyle = 'rgba(16, 32, 21, 0.035)';
ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 32) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.restore();
}
function drawGrid(ctx) {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,.46)';
ctx.strokeStyle = 'rgba(16,32,21,.055)';
ctx.lineWidth = 1;
rect(ctx, GRID.x - 10, GRID.y - 10, GRID.cols * GRID.cell + 20, GRID.rows * GRID.cell + 20, true, true);
for (let c = 0; c <= GRID.cols; c += 1) {
const x = GRID.x + c * GRID.cell;
ctx.beginPath(); ctx.moveTo(x, GRID.y); ctx.lineTo(x, GRID.y + GRID.rows * GRID.cell); ctx.stroke();
}
for (let r = 0; r <= GRID.rows; r += 1) {
const y = GRID.y + r * GRID.cell;
ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke();
}
ctx.restore();
}
function componentRatio(game, k) {
const id = game.componentLookup?.get(k);
const data = id ? game.congestion.get(id) : null;
return data?.ratio || 0;
}
function drawConveyors(ctx, game) {
const edges = [];
const seen = new Set();
for (const k of game.conveyorTiles) {
const { col, row } = parseKey(k);
for (const n of getConveyorNeighbors(game, col, row)) {
const nk = key(n.col, n.row);
const e = [k, nk].sort().join('|');
if (seen.has(e)) continue;
seen.add(e);
const ratio = Math.max(componentRatio(game, k), componentRatio(game, nk));
edges.push({ a: cellCenter(col, row), b: cellCenter(n.col, n.row), ratio });
}
}
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (const layer of [
{ width: 33, color: THEME.ink, alpha: 1 },
{ width: 25, color: THEME.white, alpha: 1 },
{ width: 17, color: THEME.greenSoft, alpha: 1 }
]) {
ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
}
for (const e of edges) {
const t = Math.max(0, (e.ratio - 0.5) / 0.5);
ctx.strokeStyle = mixHex(THEME.green, THEME.danger, t);
ctx.globalAlpha = e.ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
}
for (const k of game.conveyorTiles) {
const c = cellCenter(...Object.values(parseKey(k)));
const ratio = componentRatio(game, k);
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
}
for (const farm of game.eggFarms) {
const data = routeFromFarmToScanner(game, farm);
if (data) drawRouteFlow(ctx, data.route, THEME.muted, 3);
}
const flowSegments = new Map();
for (const scanner of game.scanners) {
for (const side of ['left', 'right']) {
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id);
if (!plan) continue;
addRouteFlowSegments(flowSegments, plan.route, plan.destination);
}
}
drawDestinationFlowSegments(ctx, flowSegments);
ctx.restore();
}
function drawRouteFlow(ctx, points, color, width) {
if (!points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.globalAlpha = .78; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
ctx.fillStyle = color; ctx.globalAlpha = .92;
for (let i = 0; i < points.length - 1; i += 1) {
const a = points[i], b = points[i + 1];
const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
if (dist < 36) continue;
drawArrow(ctx, a.x + dx * .55, a.y + dy * .55, Math.atan2(dy, dx));
}
ctx.restore();
}
function flowSegmentKey(a, b) {
const ak = `${Math.round(a.x)},${Math.round(a.y)}`;
const bk = `${Math.round(b.x)},${Math.round(b.y)}`;
return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`;
}
function addRouteFlowSegments(map, points, dest) {
if (!points || points.length < 2) return;
for (let i = 0; i < points.length - 1; i += 1) {
const a = points[i], b = points[i + 1];
if (Math.hypot(b.x - a.x, b.y - a.y) < 8) continue;
const k = flowSegmentKey(a, b);
if (!map.has(k)) map.set(k, new Map());
const dests = map.get(k);
if (!dests.has(dest)) dests.set(dest, { a, b });
}
}
function drawDestinationFlowSegments(ctx, segments) {
const order = ['mixer', 'trash', 'truck', 'scanner'];
for (const dests of segments.values()) {
const entries = [...dests.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
const count = entries.length;
entries.forEach(([dest, seg], index) => {
const offset = (index - (count - 1) / 2) * 8;
drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset);
});
}
}
function drawColoredFlowSegment(ctx, a, b, color, offset) {
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
if (dist < 8) return;
const nx = -dy / dist, ny = dx / dist;
const ax = a.x + nx * offset, ay = a.y + ny * offset;
const bx = b.x + nx * offset, by = b.y + ny * offset;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.globalAlpha = .9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.fillStyle = color; ctx.globalAlpha = .98;
if (dist > 32) drawArrow(ctx, ax + (bx - ax) * .58, ay + (by - ay) * .58, Math.atan2(dy, dx));
ctx.restore();
}
function drawArrow(ctx, x, y, angle) {
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
ctx.beginPath(); ctx.moveTo(11, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2, 0); ctx.lineTo(-6, 7); ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawScannerConnectors(ctx, game) {
ctx.save();
ctx.lineWidth = 2;
ctx.font = '900 10px ui-monospace, monospace';
ctx.textAlign = 'center';
for (const scanner of game.scanners) {
const outputs = scannerOutputs(scanner);
const items = [
{ p: scannerConnector(scanner, 'inputA'), label: 'IN', color: THEME.green },
{ p: scannerConnector(scanner, 'left'), label: destinationLabel(outputs.left), color: destinationColor(outputs.left) },
{ p: scannerConnector(scanner, 'right'), label: destinationLabel(outputs.right), color: destinationColor(outputs.right) }
];
for (const item of items) {
if (!item.p) continue;
const p = cellCenter(item.p.col, item.p.row);
const connected = game.conveyorTiles.has(key(item.p.col, item.p.row));
ctx.fillStyle = connected ? item.color : '#f4c2c2';
ctx.strokeStyle = THEME.ink;
rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true);
ctx.fillStyle = connected && item.color === THEME.white ? THEME.ink : THEME.white;
ctx.fillText(item.label, p.x, p.y + 4);
}
}
ctx.restore();
}
function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm(ctx, farm, game); }
function drawEggFarm(ctx, farm, game) {
const c = cellCenter(farm.col, farm.row);
ctx.save();
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true);
ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
ctx.font = '900 9px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26);
const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
if (shutter > 0) {
const h = 56 * shutter;
ctx.fillStyle = THEME.ink;
rect(ctx, c.x - 28, c.y - 28, 56, h, true, false);
ctx.strokeStyle = THEME.white; ctx.lineWidth = 2;
for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); }
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
}
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
ctx.restore();
}
function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ctx, s, game); }
function drawScanner(ctx, scanner, game) {
const c = scannerCenter(scanner);
ctx.save();
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; }
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 12);
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 7);
const q = scanner.queue.length;
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
ctx.restore();
}
function targetForTruck(game) {
return game.contractActive?.target || game.contractOffer?.target || 'female';
}
function drawItemIcon(ctx, x, y, type) {
if (type === 'poop') drawTinyPoop(ctx, x, y);
else drawTinyChick(ctx, x, y, type, false);
}
function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,.92)';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
rect(ctx, x, y, w, 32, true, true);
drawItemIcon(ctx, x + 18, y + 16, type);
ctx.fillStyle = THEME.ink;
ctx.font = '900 10px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(title, x + 34, y + 13);
if (subtitle) {
ctx.fillStyle = THEME.muted;
ctx.font = '900 8px ui-monospace, monospace';
ctx.fillText(subtitle, x + 34, y + 25);
}
ctx.restore();
}
function receiverTitle(id, game) {
if (id === 'mixer') return { title: 'IN: MALE', type: 'male', color: THEME.mixerBlue };
if (id === 'trash') return { title: 'IN: POOP', type: 'poop', color: THEME.wasteGreen };
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
return { title: 'IN', type: 'female', color: THEME.green };
}
function drawFacilityReceiver(ctx, game, id) {
const f = game.facilities[id];
if (!f?.entry) return;
const c = cellCenter(f.entry.col, f.entry.row);
const info = receiverTitle(id, game);
ctx.save();
ctx.fillStyle = THEME.white;
ctx.strokeStyle = info.color;
ctx.lineWidth = 5;
rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 2;
rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true);
drawItemIcon(ctx, c.x, c.y - 3, info.type);
ctx.font = '900 8px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillStyle = THEME.ink;
ctx.fillText(info.title, c.x, c.y + 20);
ctx.restore();
}
function drawFacilities(ctx, game) {
if (game.facilities.mixer) drawMixer(ctx, game);
if (game.facilities.trash) drawTrash(ctx, game);
if (game.facilities.truck) drawTruck(ctx, game);
drawFacilityReceiver(ctx, game, 'mixer');
drawFacilityReceiver(ctx, game, 'trash');
drawFacilityReceiver(ctx, game, 'truck');
}
function drawExternalDuct(ctx, f) {
if (!f?.entry) return;
const c = cellCenter(f.entry.col, f.entry.row);
let bx = f.x + f.w / 2, by = f.y + f.h / 2;
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
else if (f.side === 'right') { bx = f.x; by = c.y; }
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
ctx.save();
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
ctx.strokeStyle = THEME.white; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
ctx.restore();
}
function drawMixer(ctx, game) {
const m = game.facilities.mixer;
ctx.save();
drawExternalDuct(ctx, m);
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(game.mixerHalfTimer > 0 ? `HALF PAY ${game.mixerHalfTimer.toFixed(1)}s` : `PAY ¥5`, m.x + m.w / 2, m.y + 48);
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore();
}
function drawTrash(ctx, game) {
const t = game.facilities.trash;
ctx.save();
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
function drawTruck(ctx, game) {
const t = game.facilities.truck;
ctx.save();
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`EST ${Math.round(truckPayoutMultiplier(game) * 100)}%`, t.x + t.w / 2, t.y + 42);
const truckTargetType = targetForTruck(game);
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
function drawChicks(ctx, game, activeQueuedChick) {
const active1 = activeQueuedChick(0);
const active2 = activeQueuedChick(1);
for (const chick of game.chicks) {
const active = (active1 && active1.id === chick.id) || (active2 && active2.id === chick.id) || chick.queueIndex === 0;
drawChick(ctx, chick, active, game);
}
}
function drawChick(ctx, chick, active, game) {
const rage = game.componentLookup && nearestRatio(game, chick) > 0.8;
const y = chick.y + Math.sin(chick.bob) * (rage ? 5 : 1.5);
ctx.save();
if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); }
if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); }
if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
else {
const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
if (!drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2)) {
ctx.fillStyle = chick.sex === 'male' ? THEME.male : THEME.female;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(chick.x, y, chick.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = THEME.ink; ctx.beginPath(); ctx.arc(chick.x - 6, y - 5, 2.5, 0, Math.PI * 2); ctx.arc(chick.x + 6, y - 5, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill();
}
}
ctx.restore();
}
function nearestRatio(game, chick) {
let best = null, bestD = Infinity;
for (const [k, id] of game.componentLookup.entries()) {
const p = parseKey(k); const c = cellCenter(p.col, p.row); const d = Math.hypot(chick.x - c.x, chick.y - c.y);
if (d < bestD) { bestD = d; best = id; }
}
return game.congestion.get(best)?.ratio || 0;
}
function drawPoop(ctx, x, y, radius) {
if (drawImageIfLoaded(ctx, assets.poop, x - radius, y - radius, radius * 2, radius * 2)) return;
ctx.save(); ctx.fillStyle = THEME.poop; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(x, y + 7, radius * .86, Math.PI, 0, true); ctx.arc(x, y + 1, radius * .66, Math.PI, 0, true); ctx.arc(x, y - 5, radius * .44, Math.PI, 0, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = THEME.white; ctx.beginPath(); ctx.arc(x - 5, y + 1, 2, 0, Math.PI * 2); ctx.arc(x + 5, y + 1, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore();
}
function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
function drawTinyChick(ctx, x, y, sex, warning = false) {
ctx.save(); ctx.fillStyle = sex === 'male' ? THEME.male : THEME.female; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
ctx.restore();
}
function selectionToken(type, id) { return `${type}:${id}`; }
function isSelected(game, type, id) {
if (game.selected && game.selected.type === type && game.selected.id === id) return true;
return (game.multiSelected || []).some(sel => selectionToken(sel.type, sel.id) === selectionToken(type, id));
}
function drawSelection(ctx, game, type, id, x, y, w, h) {
if (!isSelected(game, type, id)) return;
ctx.save(); ctx.strokeStyle = THEME.green; ctx.lineWidth = 4; ctx.setLineDash([8, 5]); ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.restore();
}
function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
if (game.phase !== 'build') return;
if ((game.multiSelected || []).length > 1) {
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
return;
}
const obj = selectedObject();
if (!obj) return;
let x = 80, y = 80;
if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; }
else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; }
else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; }
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore();
}
function drawSelectionBox(ctx, game) {
if (game.phase !== 'build' || !game.selectionBox) return;
const b = game.selectionBox;
const x = Math.min(b.x1, b.x2), y = Math.min(b.y1, b.y2);
const w = Math.abs(b.x2 - b.x1), h = Math.abs(b.y2 - b.y1);
ctx.save();
ctx.fillStyle = 'rgba(34,185,79,.12)';
ctx.strokeStyle = THEME.green;
ctx.lineWidth = 2;
ctx.setLineDash([7, 4]);
rect(ctx, x, y, w, h, true, true);
ctx.restore();
}
function drawEffects(ctx, game) {
const sorted = [...game.effects].sort((a, b) => (a.priority ?? EFFECT_PRIORITY.ambient) - (b.priority ?? EFFECT_PRIORITY.ambient));
for (const e of sorted) {
const alpha = Math.max(0, e.life / e.maxLife);
ctx.save(); ctx.globalAlpha = alpha;
if (e.type === 'meat') { ctx.fillStyle = THEME.green; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.5, e.size, true, false); }
else if (e.type === 'sludge') { ctx.fillStyle = THEME.poop; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.4, e.size, true, false); }
else if (e.type === 'spawn') { ctx.strokeStyle = THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 20, 0, Math.PI * 2); ctx.stroke(); }
else if (e.type === 'spark') { ctx.fillStyle = e.color || THEME.danger; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, true, false); }
else if (e.type === 'smoke') { ctx.fillStyle = 'rgba(16,32,21,.24)'; ctx.beginPath(); ctx.arc(e.x, e.y, e.size * (1.2 - alpha * .2), 0, Math.PI * 2); ctx.fill(); }
else if (e.type === 'shockwave') { ctx.strokeStyle = e.color || THEME.danger; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * (e.maxSize || 44), 0, Math.PI * 2); ctx.stroke(); }
else if (e.type === 'scannerPulse') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 5; ctx.setLineDash([8, 6]); ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 38, 0, Math.PI * 2); ctx.stroke(); }
else if (e.type === 'shred') { ctx.fillStyle = e.color || THEME.ink; ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.rot || 0); rect(ctx, -e.w / 2, -e.h / 2, e.w, e.h, true, false); ctx.restore(); }
else if (e.type === 'load') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(e.x - 14, e.y); ctx.lineTo(e.x + 14, e.y); ctx.moveTo(e.x, e.y - 14); ctx.lineTo(e.x, e.y + 14); ctx.stroke(); }
else if (e.type === 'erase') { ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; ctx.setLineDash([4, 4]); rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, false, true); }
else if (e.type === 'rage') { ctx.fillStyle = e.color || THEME.danger; ctx.font = '900 16px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', e.x, e.y); }
else if (e.type === 'flyingChick') { e.sex === 'poop' ? drawPoop(ctx, e.x, e.y, e.radius || 11) : drawTinyChick(ctx, e.x, e.y, e.sex, e.sex === 'male'); }
ctx.restore();
}
}
function drawFloatingTexts(ctx, game) {
ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center';
for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }
ctx.restore();
}
function drawCanvasHints(ctx, game, canvas) {
ctx.save();
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
// Visible version marker helps avoid browser/file-cache confusion when testing zips.
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
const lines = [];
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');
if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect all receiver ports`);
let y = 76;
for (const line of lines) { ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; ctx.strokeText(line, 24, y); ctx.fillText(line, 24, y); y += 20; }
ctx.restore();
}
function drawDebug(ctx, game) {
ctx.save(); ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.danger;
for (const k of game.conveyorTiles) { const { col, row } = parseKey(k); const c = cellCenter(col, row); const comp = game.componentLookup?.get(k) || '?'; ctx.fillText(`${col},${row}/C${comp}`, c.x, c.y + 24); }
ctx.restore();
}