diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/assets/images/README.md b/assets/images/README.md new file mode 100644 index 0000000..5f03f73 --- /dev/null +++ b/assets/images/README.md @@ -0,0 +1,16 @@ +# Image asset folder + +The game currently uses simple canvas placeholders. You can override them by adding images with these filenames: + +- `chick_male.png` +- `chick_female.png` +- `poop.png` +- `conveyor.png` +- `scanner_manual.png` +- `scanner_auto.png` +- `egg_farm.png` +- `mixer.png` +- `shredder.png` +- `truck.png` + +The render code checks whether each image loads and falls back to simple shapes when it does not. diff --git a/index.html b/index.html new file mode 100644 index 0000000..7103dc6 --- /dev/null +++ b/index.html @@ -0,0 +1,98 @@ + + + + + + Chick Sorter v10.7 UI / Ports / Shutter + + + +
+
+ + +
+
CASH¥200
+
TIME60.0s
+
PROFIT¥0
+
TURN1
+
PHASETitle
+
+ +
+
MIXER0
+
TRUCK F0
+
WRONG0
+
POOP0
+
+ +
+
NO ACTIVE EVENT
+
TRUCK TARGET: FEMALE
+
BELT: 52 px/s
+
+ +
+
+
+

CHICK SORTER v10.7

+

Build, drag, connect all receiver ports, then start the next turn.

+
+ +
+ +
+
+

Add / Edit Equipment

+
+ + + + + + + + +
+
+
Build tools unlock after each turn.
+
+ + +
+

Irregular One-Turn Event

+
Irregular forced events appear during Build phase.
+
+ +
+

Selected Facility

+
Click equipment in Build phase.
+
+ +
+

Status

+
+
+
+
+ +
+ + + + +
+
+
+ + + + + + diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..1394001 --- /dev/null +++ b/src/README.md @@ -0,0 +1,24 @@ +# Chick Sorter v6.0 Visibility Pass + +Open `index.html` in a browser. + +## v6 changes + +- During sorting, the Build/Upgrade panel is hidden. +- During sorting, the HUD is reduced to the key items: Cash, Time, Profit, Turn, Phase. +- Turn detail counters are shown between turns instead of competing with the play field. +- Chicks are larger and have a stronger outline for dark backgrounds. +- Background and grid lines are fainter to reduce visual noise. + +## Controls + +- Scanner 1: `A` = Mixer, `D` = Truck +- Scanner 2: `←` = Mixer, `→` = Truck + +## Rules + +- Mixer: ¥5 +- Female truck: ¥10 +- Male truck: -¥10 +- Unsorted chicks randomly exit left or right at scanner output. +- Conveyor speed upgrade is removed. diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..351a922 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,63 @@ +export const VERSION = 'v10.7 UI / Ports / Shutter'; + +export const TURN_SECONDS = 60; +export const STARTING_CASH = 200; + +export const MIXER_PRICE = 5; +export const TRUCK_PRICE = 10; +export const POOP_RATE = 0.08; +export const MIXER_HALF_SECONDS = 10; +export const TRUCK_POOP_DECAY = 0.01; +export const CONVEYOR_SPEED = 52; +export const CONVEYOR_SPEED_GROWTH = 1.02; +export const AUTO_SCANNER_COOLDOWN = 4.5; +export const CONTRACT_EVENT_CHANCE = 0.70; +export const CONTRACT_EVENT_FIRST_TURN = 8; + +export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 }; + +export const BUILD_COSTS = { + conveyor: 30, + eggFarm: 220, + manualScanner: 260, + autoScanner: 360, + mixer: 450, + trash: 240, + truck: 450 +}; + +export const FACILITY_PRICES = { + mixer: 450, + trash: 240, + truck: 450 +}; + +export const DIRS = [ + { name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' }, + { name: 'left', dc: -1, dr: 0, angle: Math.PI, opposite: 'right' }, + { name: 'down', dc: 0, dr: 1, angle: Math.PI / 2, opposite: 'up' }, + { name: 'up', dc: 0, dr: -1, angle: -Math.PI / 2, opposite: 'down' } +]; + +export const THEME = { + bg: '#eefbea', + ink: '#102015', + muted: '#526456', + green: '#22b94f', + greenSoft: '#b7f5c6', + mixerBlue: '#2477ff', + truckPink: '#ff6aa8', + wasteGreen: '#22b94f', + danger: '#c42424', + warn: '#f0a020', + white: '#ffffff', + poop: '#7b4a23', + male: '#62a9ff', + female: '#ff8bc6' +}; + +export const EFFECT_PRIORITY = { + ambient: 0, + important: 1, + critical: 2 +}; diff --git a/src/core/entities.js b/src/core/entities.js new file mode 100644 index 0000000..b3f99b5 --- /dev/null +++ b/src/core/entities.js @@ -0,0 +1,94 @@ +import { BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js'; +import { nextSpawnDelay } from './state.js'; +import { currentPoopRate } from '../systems/contracts.js'; + +export function createEggFarm(game, col, row) { + const farm = { + type: 'eggFarm', id: game.nextId++, col, row, + level: 1, nextSpawn: 2, lastInterval: 2, + price: BUILD_COSTS.eggFarm, + builtSession: game.buildSession + }; + farm.nextSpawn = nextSpawnDelay(farm); + farm.lastInterval = farm.nextSpawn; + return farm; +} + +export function createScanner(game, col, row, kind) { + const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner; + const manualCount = game.scanners.filter(s => s.kind === 'manual').length; + return { + type: 'scanner', id: game.nextId++, kind, + slot: kind === 'manual' ? manualCount : null, + role: manualCount % 2, + col, row, level: 1, + queue: [], cooldown: 0, + price: cost, + builtSession: game.buildSession, + autoMode: 'standard' + }; +} + +export function nearestGridEdge(point) { + const colFloat = (point.x - GRID.x) / GRID.cell; + const rowFloat = (point.y - GRID.y) / GRID.cell; + const col = Math.max(0, Math.min(GRID.cols - 1, Math.round(colFloat - 0.5))); + const row = Math.max(0, Math.min(GRID.rows - 1, Math.round(rowFloat - 0.5))); + const left = Math.abs(point.x - GRID.x); + const right = Math.abs(point.x - (GRID.x + GRID.cols * GRID.cell)); + const top = Math.abs(point.y - GRID.y); + const bottom = Math.abs(point.y - (GRID.y + GRID.rows * GRID.cell)); + const side = [['left', left], ['right', right], ['top', top], ['bottom', bottom]].sort((a, b) => a[1] - b[1])[0][0]; + if (side === 'left') return { side, entry: { col: 0, row } }; + if (side === 'right') return { side, entry: { col: GRID.cols - 1, row } }; + if (side === 'top') return { side, entry: { col, row: 0 } }; + return { side, entry: { col, row: GRID.rows - 1 } }; +} + +export function layoutFacilityOnEdge(facility, entry, side) { + const center = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 }; + const edge = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell }; + facility.entry = { ...entry }; + facility.side = side; + if (side === 'left') { facility.x = edge.left - facility.w - 18; facility.y = center.y - facility.h / 2; } + else if (side === 'right') { facility.x = edge.right + 18; facility.y = center.y - facility.h / 2; } + else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = edge.top - facility.h - 18; } + else { facility.x = center.x - facility.w / 2; facility.y = edge.bottom + 18; } + return facility; +} + +export function createFacility(game, id, p, price) { + const { entry, side } = nearestGridEdge(p); + const spec = { + mixer: { name: 'Mixer', w: 164, h: 126, price: FACILITY_PRICES.mixer }, + trash: { name: 'Waste Shredder', w: 180, h: 100, price: FACILITY_PRICES.trash }, + truck: { name: 'Truck', w: 176, h: 138, price: FACILITY_PRICES.truck } + }[id] || { name: id[0].toUpperCase() + id.slice(1), w: 168, h: 110, price }; + const f = { + type: 'facility', id, + name: spec.name, + x: 0, y: 0, w: spec.w, h: spec.h, + level: 1, + price: price || spec.price, + builtSession: game.buildSession + }; + return layoutFacilityOnEdge(f, entry, side); +} + +export function createChick(game, routeData) { + const sex = Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female'); + const start = routeData.route[0]; + return { + id: game.nextId++, sex, + x: start.x, y: start.y, + route: routeData.route, + targetIndex: 1, + stage: 'input', + scannerId: routeData.scannerId, + nextScannerId: null, + radius: sex === 'poop' ? 15 : 17, + bob: Math.random() * Math.PI * 2, + queueIndex: -1, + queueRoute: null + }; +} diff --git a/src/core/state.js b/src/core/state.js new file mode 100644 index 0000000..8f8d2e0 --- /dev/null +++ b/src/core/state.js @@ -0,0 +1,155 @@ +import { STARTING_CASH, TURN_SECONDS, BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js'; +import { randomBetween } from './utils.js'; + +export function newTurnStats() { + return { + mixerCount: 0, + truckFemale: 0, + truckMale: 0, + trashCount: 0, + poopSpawned: 0, + poopTruck: 0, + poopTrash: 0, + poopMixer: 0, + autoSorted: 0, + revenue: 0, + penalty: 0, + profit: 0, + pendingTruckRevenue: 0, + explosionDamage: 0, + correct: 0, + mistakes: 0, + contractBonus: 0, + contractResult: null + }; +} + +export function newTotalStats() { + return { + turnsCompleted: 0, + processed: 0, + correct: 0, + mistakes: 0, + revenue: 0, + penalty: 0, + profit: 0, + maxCash: STARTING_CASH, + autoSorted: 0, + poopTruck: 0, + poopTrash: 0, + poopMixer: 0, + explosionDamage: 0, + contractBonus: 0, + contractSuccess: 0, + contractFailed: 0 + }; +} + +export function createGame() { + return { + phase: 'title', + cash: STARTING_CASH, + turn: 1, + timeLeft: TURN_SECONDS, + lastTimestamp: 0, + nextId: 10, + buildTool: null, + buildSession: 0, + undoStack: [], + redoStack: [], + selected: null, + multiSelected: [], + selectionBox: null, + groupDrag: null, + pan: null, + view: { x: 0, y: 0 }, + debug: false, + chicks: [], + effects: [], + floatingTexts: [], + shake: { time: 0, strength: 0 }, + truckCargo: [], + conveyorTiles: new Set(), + conveyorMeta: new Map(), + eggFarms: [], + scanners: [], + facilities: {}, + branchCounters: new Map(), + mixerHalfTimer: 0, + congestion: new Map(), + lastExplodedComponent: new Map(), + contractOffer: null, + contractActive: null, + stats: newTurnStats(), + lastResult: null, + totals: newTotalStats() + }; +} + +export function getSpawnRange(farm) { + const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]]; + return ranges[(farm?.level || 1) - 1] || ranges[0]; +} + +export function nextSpawnDelay(farm) { + const [min, max] = getSpawnRange(farm); + return randomBetween(min, max); +} + +function facilityBodyForEntry(id, entry, side) { + const sizes = { + mixer: { w: 164, h: 126, name: 'Mixer', price: FACILITY_PRICES.mixer }, + trash: { w: 180, h: 100, name: 'Waste Shredder', price: FACILITY_PRICES.trash }, + truck: { w: 176, h: 138, name: 'Truck', price: FACILITY_PRICES.truck } + }; + const g = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell }; + const c = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 }; + const spec = sizes[id]; + let x = c.x - spec.w / 2; + let y = c.y - spec.h / 2; + if (side === 'left') { x = g.left - spec.w - 18; y = c.y - spec.h / 2; } + else if (side === 'right') { x = g.right + 18; y = c.y - spec.h / 2; } + else if (side === 'bottom') { x = c.x - spec.w / 2; y = g.bottom + 18; } + else if (side === 'top') { x = c.x - spec.w / 2; y = g.top - spec.h - 18; } + return { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, level: 1, price: spec.price, entry: { ...entry }, side }; +} + +export function defaultFacilities() { + return { + // Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge. + mixer: facilityBodyForEntry('mixer', { col: 0, row: 6 }, 'left'), + trash: facilityBodyForEntry('trash', { col: 16, row: 12 }, 'bottom'), + truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right') + }; +} + +export function resetLayout(game) { + game.conveyorTiles.clear(); + game.conveyorMeta.clear(); + const initial = [ + // Egg Farm -> S1 top input. The approach lane is long enough to read the flow. + [4, 1], [4, 2], [4, 3], [4, 4], [4, 5], + // S1 left output -> Mixer receiver on the left grid edge. + [3, 6], [2, 6], [1, 6], [0, 6], + // S1 right output -> S2 top input. This avoids touching S2's waste output lane. + [5, 6], [6, 6], [7, 6], [8, 6], [9, 6], [10, 6], [11, 6], [12, 6], [13, 6], [14, 6], + [14, 5], [15, 5], [16, 5], [17, 5], [17, 6], + // S2 left output -> Waste Shredder receiver on bottom grid edge. + [16, 7], [16, 8], [16, 9], [16, 10], [16, 11], [16, 12], + // S2 right output -> Truck receiver on the right grid edge. + [18, 7], [19, 7], [20, 7], [21, 7] + ]; for (const [col, row] of initial) { + const k = `${col},${row}`; + game.conveyorTiles.add(k); + game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null }); + } + game.facilities = defaultFacilities(); + game.scanners = [ + { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 6, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }, + { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 17, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' } + ]; + const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }; + farm.nextSpawn = nextSpawnDelay(farm); + farm.lastInterval = farm.nextSpawn; + game.eggFarms = [farm]; +} diff --git a/src/core/utils.js b/src/core/utils.js new file mode 100644 index 0000000..c726dfc --- /dev/null +++ b/src/core/utils.js @@ -0,0 +1,32 @@ +import { GRID } from './config.js'; + +export function yen(value) { return `¥${Math.floor(value).toLocaleString('en-US')}`; } +export function key(col, row) { return `${col},${row}`; } +export function parseKey(k) { const [col, row] = String(k).split(',').map(Number); return { col, row }; } +export function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } +export function randomBetween(min, max) { return min + Math.random() * (max - min); } +export function inGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; } +export function cellCenter(col, row) { + return { x: GRID.x + col * GRID.cell + GRID.cell / 2, y: GRID.y + row * GRID.cell + GRID.cell / 2 }; +} +export function pointToCell(x, y) { + const col = Math.floor((x - GRID.x) / GRID.cell); + const row = Math.floor((y - GRID.y) / GRID.cell); + if (!inGrid(col, row)) return null; + return { col, row }; +} +export function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } +export function sameCell(a, b) { return a && b && a.col === b.col && a.row === b.row; } +export function lerp(a, b, t) { return a + (b - a) * t; } +export function hexToRgb(hex) { + const s = hex.replace('#', ''); + return { r: parseInt(s.slice(0, 2), 16), g: parseInt(s.slice(2, 4), 16), b: parseInt(s.slice(4, 6), 16) }; +} +export function rgbToHex(r, g, b) { + const c = n => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0'); + return `#${c(r)}${c(g)}${c(b)}`; +} +export function mixHex(a, b, t) { + const ca = hexToRgb(a), cb = hexToRgb(b); + return rgbToHex(lerp(ca.r, cb.r, t), lerp(ca.g, cb.g, t), lerp(ca.b, cb.b, t)); +} diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..e75fc2f --- /dev/null +++ b/src/game.js @@ -0,0 +1,424 @@ +import { VERSION, TURN_SECONDS, STARTING_CASH, POOP_RATE, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from './core/config.js'; +import { createGame, resetLayout, newTurnStats, newTotalStats, nextSpawnDelay, getSpawnRange } from './core/state.js'; +import { createChick } from './core/entities.js'; +import { key, parseKey, clamp, randomBetween, pointToCell, cellCenter, distance, yen } from './core/utils.js'; +import { farmAt, scannerAt, scannerById, scannerBySlot, scannerCenter, scannerConnector, routeFromFarmToScanner, outputRoute, scannerOutputs, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, factoryReady, facilityConnectionIssues } from './systems/routing.js'; +import { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS, maleTruckPenalty, truckPayoutMultiplier, positivePayout, applyCashDelta, spendCash, refundCash, settleTruckRevenue, finalScore } from './systems/economy.js'; +import { snapshot, record, undo, redo } from './systems/history.js'; +import { drawAll } from './render/draw.js'; +import { createBuildSystem } from './systems/buildSystem.js'; +import { createUISystem } from './systems/uiSystem.js'; +import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, eraseEffect, rageEffect, updateEffects } from './systems/effects.js'; +import { rollContractOffer, activateAcceptedContract, clearActiveContract, productionMultiplier, resolveContract, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './systems/contracts.js'; + +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); + +const ui = { + shell: document.getElementById('gameShell'), + money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), + eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'), + turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'), + buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'), + modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'), + buttons: { + s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'), + nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton') + } +}; + +const game = createGame(); +let build; +let uiSystem; + +function conveyorSpeedForTurn(turn) { + return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1)); +} +function currentConveyorSpeed() { return conveyorSpeedForTurn(game.turn); } + +function startGame() { + Object.assign(game, createGame()); + game.phase = 'running'; + resetLayout(game); + uiSystem.hideModal(); + updateCongestion(); + uiSystem.updatePanels(); + uiSystem.updateUI(); +} + +function startNextTurn() { + if (game.phase !== 'build') return; + const issues = facilityConnectionIssues(game); + if (issues.length) { + build.fail(`Cannot start: ${issues[0]}`); + uiSystem.updatePanels(); + return; + } + activateAcceptedContract(game); + game.phase = 'running'; + game.turn += 1; + game.timeLeft = TURN_SECONDS; + game.chicks = []; + game.effects = []; + game.floatingTexts = []; + game.shake = { time: 0, strength: 0 }; + game.truckCargo = []; + game.stats = newTurnStats(); + for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; } + for (const farm of game.eggFarms) { farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; } + game.buildTool = null; + game.selected = null; + updateCongestion(); + uiSystem.updatePanels(); + uiSystem.updateUI(); +} + +function completeTurn() { + if (game.phase !== 'running') return; + const ship = settleTruckRevenue(game); + const contract = resolveContract(game, applyCashDelta); + clearActiveContract(game); + if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } + game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash }; + game.phase = 'build'; + game.truckCargo = []; + game.buildSession += 1; + game.undoStack = []; + game.redoStack = []; + game.timeLeft = 0; + game.totals.turnsCompleted += 1; + for (const scanner of game.scanners) scanner.queue = []; + game.contractOffer = rollContractOffer(game); + game.buildTool = null; + game.groupDrag = null; + game.selectionBox = null; + game.pan = null; + uiSystem.updatePanels(); + uiSystem.showTurnResult(game.lastResult); +} + +function closeFarmShutters() { + for (const farm of game.eggFarms) { + farm.shutterProgress = 0.02; + farm.shutterSparked = true; + const c = cellCenter(farm.col, farm.row); + shockwave(game, c.x, c.y, THEME.ink); + sparkBurst(game, c.x, c.y + 10, 10); + floating(game, c.x, c.y - 34, 'SHUT', THEME.ink); + } +} + +function spawnChick(farm) { + const data = routeFromFarmToScanner(game, farm, true); + if (!data) { floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 18, 'NO ROUTE', THEME.danger); return false; } + const chick = createChick(game, data); + if (chick.sex === 'poop') game.stats.poopSpawned += 1; + const start = data.route[0]; + if (isSpawnBlocked(start)) { explodeRoute(data.route, [{ sex: chick.sex, x: start.x, y: start.y }], 'LINE JAM!'); return false; } + game.chicks.push(chick); + spawnPulse(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y); + return true; +} +function isSpawnBlocked(start) { return game.chicks.some(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68); } +function currentSpawnDelay(farm) { return nextSpawnDelay(farm) / productionMultiplier(game); } + +function update(timestamp) { + if (!game.lastTimestamp) game.lastTimestamp = timestamp; + const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05); + game.lastTimestamp = timestamp; + if (game.phase === 'running') updateRunning(dt); + updateEffects(game, dt, canvas, build.equipmentHitBoxes); + updateCongestion(); + drawAll(ctx, canvas, game, { activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle }); + uiSystem.updateUI(); + requestAnimationFrame(update); +} + +function updateRunning(dt) { + const producing = game.timeLeft > 0; + game.timeLeft = Math.max(0, game.timeLeft - dt); + if (producing && game.timeLeft <= 0) closeFarmShutters(); + if (game.timeLeft <= 0) { + for (const farm of game.eggFarms) farm.shutterProgress = Math.min(1, (farm.shutterProgress || 0) + dt * 2.8); + } + if (game.mixerHalfTimer > 0) game.mixerHalfTimer = Math.max(0, game.mixerHalfTimer - dt); + if (producing && game.timeLeft > 0) { + for (const farm of game.eggFarms) { + farm.nextSpawn -= dt; + if (farm.nextSpawn <= 0) { spawnChick(farm); farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; } + } + } + for (let i = game.chicks.length - 1; i >= 0; i -= 1) { + const chick = game.chicks[i]; + chick.bob += dt * 7; + if (chick.stage === 'queued') continue; + updateRoutedChick(chick, i, dt); + } + updateScannerQueues(dt); + updateCongestion(); + checkCongestionExplosions(dt); + if (game.timeLeft <= 0 && game.chicks.length === 0) completeTurn(); + uiSystem.checkGameOver(); +} + +function updateRoutedChick(chick, index, dt) { + if (blockedByFrontChick(chick)) return; + moveAlongRoute(chick, dt); + if (chick.route && chick.targetIndex < chick.route.length) return; + if (chick.stage === 'input' || chick.stage === 'toScanner') { + const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId); + if (!scanner) { removeChick(index, 'NO SCANNER'); return; } + enqueueChick(chick, scanner, chick.route); + return; + } + if (chick.stage === 'toMixer') resolveMixer(index); + else if (chick.stage === 'toTruck') resolveTruck(index); + else if (chick.stage === 'toTrash') resolveTrash(index); +} +function blockedByFrontChick(chick) { + if (!chick.route || chick.targetIndex >= chick.route.length) return false; + const next = chick.route[chick.targetIndex]; + for (const other of game.chicks) { + if (other.id === chick.id || other.stage === 'flying') continue; + if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true; + } + return false; +} +function routeProgress(chick) { return chick.targetIndex || 0; } +function moveAlongRoute(chick, dt) { + let remaining = currentConveyorSpeed() * dt; + while (remaining > 0 && chick.targetIndex < chick.route.length) { + const target = chick.route[chick.targetIndex]; + const dx = target.x - chick.x, dy = target.y - chick.y; + const dist = Math.hypot(dx, dy); + if (dist <= 0.001) { chick.targetIndex += 1; continue; } + if (remaining >= dist) { chick.x = target.x; chick.y = target.y; chick.targetIndex += 1; remaining -= dist; } + else { chick.x += dx / dist * remaining; chick.y += dy / dist * remaining; remaining = 0; } + } +} +function enqueueChick(chick, scanner, routeOverride) { + chick.stage = 'queued'; + chick.route = null; + chick.queueRoute = routeOverride ? routeOverride.map(p => ({ x: p.x, y: p.y })) : [scannerCenter(scanner)]; + chick.scannerId = scanner.id; + if (!scanner.queue.includes(chick.id)) scanner.queue.push(chick.id); + positionScannerQueue(scanner); +} +function positionScannerQueue(scanner) { + scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued')); + const spacing = GRID.cell * 0.86; + scanner.queue.forEach((id, idx) => { + const chick = game.chicks.find(ch => ch.id === id); + if (!chick) return; + setPositionFromRouteEnd(chick, chick.queueRoute || [scannerCenter(scanner)], idx * spacing); + chick.queueIndex = idx; + }); +} +function setPositionFromRouteEnd(chick, route, distanceBack) { + if (!route || !route.length) return; + if (route.length === 1 || distanceBack <= 0) { const end = route[route.length - 1]; chick.x = end.x; chick.y = end.y; return; } + let remain = distanceBack; + for (let i = route.length - 1; i > 0; i -= 1) { + const a = route[i - 1], b = route[i]; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (remain <= len) { const t = 1 - remain / len; chick.x = a.x + (b.x - a.x) * t; chick.y = a.y + (b.y - a.y) * t; return; } + remain -= len; + } + const start = route[0]; chick.x = start.x; chick.y = start.y; +} +function updateScannerQueues(dt) { + for (const scanner of game.scanners) { + if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt); + positionScannerQueue(scanner); + if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue; + const id = scanner.queue[0]; + const index = game.chicks.findIndex(c => c.id === id); + if (index < 0) { scanner.queue.shift(); continue; } + if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) { scanner.cooldown = AUTO_SCANNER_COOLDOWN; game.stats.autoSorted += 1; game.totals.autoSorted += 1; } + } +} +function autoSideFor(scanner, chick) { + if (scanner.role === 0) return chick.sex === 'male' ? 'left' : 'right'; + return chick.sex === 'poop' ? 'left' : 'right'; +} +function activeQueuedChick(slot) { + const scanner = scannerBySlot(game, slot); + if (!scanner || !scanner.queue.length) return null; + return game.chicks.find(c => c.id === scanner.queue[0]) || null; +} +function sortSlot(slot, side) { + if (game.phase !== 'running') return; + const scanner = scannerBySlot(game, slot); + if (!scanner || !scanner.queue.length) return; + const index = game.chicks.findIndex(c => c.id === scanner.queue[0]); + if (index >= 0) sortChickByIndex(index, side, false); +} +function sortChickByIndex(index, side, auto) { + const chick = game.chicks[index]; + if (!chick || chick.stage !== 'queued') return false; + const scanner = scannerById(game, chick.scannerId); + if (!scanner) return false; + const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true); + if (!plan) { floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); return false; } + scanner.queue = scanner.queue.filter(id => id !== chick.id); + chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`; + chick.route = plan.route; chick.targetIndex = 1; chick.nextScannerId = plan.nextScannerId || null; chick.queueRoute = null; chick.queueIndex = -1; + scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination)); + floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination)); + return true; +} +function resolveMixer(index) { + const chick = game.chicks[index]; if (!chick) return; + const { x, y } = chick; game.chicks.splice(index, 1); game.stats.mixerCount += 1; game.totals.processed += 1; + if (chick.sex === 'poop') { game.mixerHalfTimer = MIXER_HALF_SECONDS; game.stats.poopMixer += 1; game.totals.poopMixer += 1; game.stats.mistakes += 1; game.totals.mistakes += 1; sludgeEffect(game, x, y); floating(game, x, y - 18, `HALF PAY ${MIXER_HALF_SECONDS}s`, THEME.ink); return; } + const amount = positivePayout(game, MIXER_PRICE); applyCashDelta(game, amount); if (chick.sex === 'male') { game.stats.correct += 1; game.totals.correct += 1; } else { game.stats.mistakes += 1; game.totals.mistakes += 1; } meatEffect(game, x, y); floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); uiSystem.checkGameOver(); +} +function resolveTruck(index) { + const chick = game.chicks[index]; if (!chick) return; + const { x, y } = chick; game.chicks.splice(index, 1); addTruckCargo(chick.sex); truckLoadEffect(game, x, y, chick.sex); game.totals.processed += 1; + const target = truckTarget(game); + const isTarget = isTargetTruckCargo(game, chick.sex); + if (chick.sex === 'poop') { + game.stats.poopTruck += 1; game.totals.poopTruck += 1; + if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); } + else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `SOIL ${Math.round(truckPayoutMultiplier(game) * 100)}%`, THEME.ink); } + return; + } + if (chick.sex === 'female') { + game.stats.truckFemale += 1; + if (isTarget) { const displayAmount = positivePayout(game, Math.floor(TRUCK_PRICE * truckPayoutMultiplier(game))); game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(displayAmount)}`, THEME.green); } + else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, target === 'male' ? 'REJECT' : 'NO SALE', THEME.warn); } + return; + } + game.stats.truckMale += 1; + if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); return; } + if (shouldFineMaleTruck(game)) { const penalty = maleTruckPenalty(game); applyCashDelta(game, -penalty); game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); uiSystem.checkGameOver(); } + else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'REJECT', THEME.warn); } +} +function resolveTrash(index) { + const chick = game.chicks[index]; if (!chick) return; + const { x, y } = chick; game.chicks.splice(index, 1); shredEffect(game, x, y, chick.sex); game.stats.trashCount += 1; game.totals.processed += 1; + if (chick.sex === 'poop') { game.stats.poopTrash += 1; game.totals.poopTrash += 1; game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, 'CLEAN', THEME.green); } + else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'WASTE', THEME.ink); } +} +function removeChick(index, label) { const chick = game.chicks[index]; if (!chick) return; game.chicks.splice(index, 1); floating(game, chick.x, chick.y - 12, label, THEME.muted); } +function addTruckCargo(sex) { const t = game.facilities.truck; if (!t) return; game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) }); if (game.truckCargo.length > 45) game.truckCargo.shift(); } + +function conveyorKeyAtChick(chick) { + const cell = pointToCell(chick.x, chick.y); + if (cell) { + const k = key(cell.col, cell.row); + if (game.conveyorTiles.has(k)) return k; + } + // Fallback only when the chick is visually still on a conveyor center. + const nearest = nearestConveyorKey(game, chick.x, chick.y); + if (!nearest) return null; + const p = parseKey(nearest); + const c = cellCenter(p.col, p.row); + return Math.hypot(chick.x - c.x, chick.y - c.y) <= GRID.cell * 0.55 ? nearest : null; +} + +function updateCongestion() { + const { components, cellToComponent } = buildConveyorComponents(game); + for (const chick of game.chicks) { + if (chick.stage === 'flying') continue; + const k = conveyorKeyAtChick(chick); + if (!k) continue; + const id = cellToComponent.get(k); + const comp = components.get(id); + if (comp) comp.count += 1; + } + for (const comp of components.values()) comp.ratio = comp.count / comp.capacity; + game.congestion = components; + game.componentLookup = cellToComponent; +} +function checkCongestionExplosions(dt) { + for (const comp of game.congestion.values()) { + if (comp.ratio > 0.8 && comp.ratio < 1) { + for (const chick of chicksInComponent(comp.id).slice(0, 3)) if (Math.random() < 0.06) rageEffect(game, chick.x, chick.y - chick.radius - 8); + } + if (comp.ratio >= 1) { + const last = game.lastExplodedComponent.get(comp.id) || 0; + const now = performance.now(); + if (now - last > 900) { game.lastExplodedComponent.set(comp.id, now); explodeComponent(comp); } + } + } +} +function chicksInComponent(id) { + return game.chicks.filter(ch => { const k = conveyorKeyAtChick(ch); return k && game.componentLookup?.get(k) === id; }); +} +function explodeComponent(comp) { + const victims = chicksInComponent(comp.id); + if (!victims.length) return; + for (const v of victims) { flyingDebris(game, v.sex, v.x, v.y); } + game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id)); + for (const scanner of game.scanners) scanner.queue = scanner.queue.filter(id => game.chicks.some(c => c.id === id)); + for (const k of comp.cells) { const p = parseKey(k); const c = cellCenter(p.col, p.row); shockwave(game, c.x, c.y, THEME.danger); sparkBurst(game, c.x, c.y, 6); smokeBurst(game, c.x, c.y, 4); } + shake(game, 18, .75); const first = parseKey(comp.cells[0]); const fc = cellCenter(first.col, first.row); floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger); +} +function explodeRoute(route, extras, label) { + const victims = game.chicks.filter(ch => route.some(p => Math.hypot(ch.x - p.x, ch.y - p.y) < GRID.cell)); + game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id)); + for (const v of victims) flyingDebris(game, v.sex, v.x, v.y); + for (const e of extras) flyingDebris(game, e.sex, e.x, e.y); + for (const p of route) { shockwave(game, p.x, p.y, THEME.danger); sparkBurst(game, p.x, p.y, 4); smokeBurst(game, p.x, p.y, 3); } + shake(game, 18, .75); if (route[0]) floating(game, route[0].x, route[0].y - 18, label, THEME.danger); +} + +// Build/edit/selection operations live in src/systems/buildSystem.js. +// HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js. +// Hitbox generation lives in buildSystem.js. +function rawCanvasPoint(event) { const r = canvas.getBoundingClientRect(); return { x: (event.clientX - r.left) * canvas.width / r.width, y: (event.clientY - r.top) * canvas.height / r.height }; } +function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x - game.view.x, y: p.y - game.view.y }; } +function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; } +function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); } +function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; } +function clickSelect(event) { + const p = canvasPoint(event), hit = build.equipmentAtPoint(p); + const sel = selectionForHit(hit); + if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; } + game.selected = sel; + game.multiSelected = [sel]; + uiSystem.updatePanels(); + if (hit.type === 'scanner' && hit.ref.kind === 'auto') build.showAutoScannerMenu(hit.ref); +} + +canvas.addEventListener('contextmenu', event => event.preventDefault()); +canvas.addEventListener('pointerdown', event => { + if (game.phase !== 'build') return; + canvas.setPointerCapture(event.pointerId); + const world = canvasPoint(event); + if (event.button === 2) { startPan(event); return; } + if (event.button !== 0) return; + if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; } + if (game.buildTool) { build.buildAtPoint(world); return; } + if (build.startGroupDrag(event)) return; + build.startSelectionBox(event); +}); +canvas.addEventListener('pointermove', event => { + if (game.phase !== 'build') return; + if (game.pan && (event.buttons & 2)) updatePan(event); + if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event); + if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event); + if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.buildAtCell(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); + if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event)); +}); +canvas.addEventListener('pointerup', event => { + if (game.selectionBox) build.finishSelectionBox(); + else if (game.groupDrag) { + if (!game.groupDrag.committed) clickSelect(event); + build.finishGroupDrag(); + } + game.groupDrag = null; game.pan = null; + try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ } +}); +canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; }); + +ui.buttons.s1Left.addEventListener('click', () => sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => sortSlot(1, 'right')); +ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); +ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); +window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } }); + +build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() }); +uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick }); +resetLayout(game); updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..cfd5067 --- /dev/null +++ b/src/index.html @@ -0,0 +1,81 @@ + + + + + + Chick Sorter v6.0 Visibility Pass + + + +
+
+ + +
+
CASH¥140
+
TIME60.0s
+
PROFIT¥0
+
TURN1
+
PHASETitle
+
+ +
+
MIXER0
+
TRUCK F0
+
WRONG0
+
RANDOM0
+
+ +
+
+
+

CHICK SORTER v6.0

+

Industrial scanner junctions. Conveyor tiles can bend; scanners split to left/right output belts.

+
+ +
+ +
+
+

Build

+
+ + + + +
+
Build tools unlock after each 60-second turn.
+
+ +
+

Upgrades

+
+
+ +
+

Live Rules

+
+
+
+
+ +
+ + + + +
+
+
+ + + + + + diff --git a/src/render/draw.js b/src/render/draw.js new file mode 100644 index 0000000..4c3b70d --- /dev/null +++ b/src/render/draw.js @@ -0,0 +1,526 @@ +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(); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..5efcfbd --- /dev/null +++ b/src/styles.css @@ -0,0 +1,352 @@ +:root { + --bg: #020303; + --screen: #050607; + --panel: rgba(10, 12, 14, 0.94); + --panel-solid: #0b0d0f; + --panel-2: #111417; + --line: rgba(132, 140, 148, 0.18); + --line-strong: rgba(177, 185, 193, 0.62); + --text: #d7dadd; + --muted: #8d949b; + --accent: #b4bac0; + --accent-2: #b99561; + --danger: #d66d6d; + --good: #9fd27d; + --shadow: 0 22px 70px rgba(0, 0, 0, 0.70); +} + +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { + margin: 0; + background: + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(180deg, rgba(255,255,255,0.010) 1px, transparent 1px), + #020303; + background-size: 24px 24px; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + overflow-x: hidden; +} + +button { font: inherit; } +h1, h2, p { margin: 0; } + +#app { + width: min(100vw, 1680px); + margin: 0 auto; + padding: 0 8px 12px; +} + +.game-shell { + position: relative; + width: 100%; + margin-top: 0; + min-height: min(100vh, 920px); + border: 1px solid #30363d; + border-top: 0; + border-radius: 0; + background: var(--screen); + box-shadow: var(--shadow); + overflow: hidden; +} + +.game-shell::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255,255,255,0.035), transparent 90px); + z-index: 1; +} + +#gameCanvas { + position: relative; + z-index: 0; + display: block; + width: 100%; + height: min(calc(100vh - 10px), 900px); + min-height: 660px; + background: #050607; + touch-action: none; +} + +.hud { + position: absolute; + z-index: 4; + display: grid; + gap: 6px; + pointer-events: none; +} +.hud-top-left { + top: 10px; + left: 10px; + grid-template-columns: repeat(5, minmax(78px, auto)); +} +.hud-top-right { + top: 10px; + right: 10px; + grid-template-columns: repeat(4, minmax(78px, auto)); +} +.hud-card { + min-width: 78px; + padding: 6px 8px; + border: 1px solid rgba(82, 91, 100, 0.76); + border-radius: 0; + background: rgba(9, 11, 13, 0.92); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018), 0 8px 20px rgba(0, 0, 0, 0.26); + backdrop-filter: blur(3px); +} +.hud-card span { + display: block; + font-size: 9px; + line-height: 1; + letter-spacing: 0.09em; + color: #7c858e; + margin-bottom: 4px; +} +.hud-card strong { + font-size: clamp(15px, 1.35vw, 21px); + line-height: 1; +} +.hud-card.cash strong { color: #d7b27d; } +.hud-card.compact strong { font-size: clamp(13px, 1.1vw, 18px); } +.hud-card.profit strong { color: var(--good); } + +.cash-negative { color: var(--danger) !important; } +.cash-positive { color: var(--good) !important; } + +.upgrade-panel { + position: absolute; + z-index: 5; + left: 10px; + right: 10px; + bottom: 64px; + display: grid; + gap: 9px; + padding: 10px; + border: 1px solid #343a40; + border-radius: 0; + background: rgba(8, 10, 12, 0.94); + box-shadow: 0 20px 55px rgba(0, 0, 0, 0.56); + backdrop-filter: blur(4px); +} +.panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; +} +.panel-head h1 { + font-size: 18px; + letter-spacing: 0.08em; +} +.panel-head h1 span { + color: #050607; + font-size: 11px; + background: #b4bac0; + border-radius: 0; + padding: 2px 7px; + vertical-align: middle; +} +.panel-head p, +.mini-box, +.upgrade-desc { color: var(--muted); } +.panel-grid { + display: grid; + grid-template-columns: 260px minmax(420px, 1fr) 340px; + gap: 10px; +} +.upgrade-panel h2 { + color: var(--accent); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.10em; + margin-bottom: 7px; +} +.tool-list, +.upgrade-list { + display: grid; + gap: 7px; +} +.tool-list { grid-template-columns: 1fr 1fr; } +.tool-button, +.primary-button, +.sort-button, +.buy-button { + border-radius: 0; + cursor: pointer; + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.035em; + transition: transform 0.06s ease, filter 0.12s ease, opacity 0.12s ease, border-color 0.12s ease; +} +.tool-button:active, +.primary-button:active, +.sort-button:active, +.buy-button:active { transform: translateY(1px); } +.tool-button:disabled, +.primary-button:disabled, +.sort-button:disabled, +.buy-button:disabled { + cursor: not-allowed; + opacity: 0.38; +} +.tool-button { + color: var(--text); + background: #111417; + border: 1px solid rgba(82, 91, 100, 0.76); + padding: 8px 9px; + text-align: left; + font-size: 11px; +} +.tool-button.active { + border-color: #b4bac0; + box-shadow: inset 0 0 0 1px rgba(180, 186, 192, 0.35); +} +.tool-button.danger.active { + border-color: var(--danger); + box-shadow: inset 0 0 0 1px rgba(214, 109, 109, 0.30); +} +.primary-button { + border: 1px solid #b4bac0; + padding: 9px 16px; + background: #b4bac0; + color: #050607; + white-space: nowrap; +} +.upgrade-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.upgrade-item { + border: 1px solid #30363d; + border-radius: 0; + padding: 8px; + background: #0f1215; +} +.upgrade-head { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.upgrade-head strong { font-size: 12px; } +.upgrade-level { color: var(--muted); font-size: 11px; white-space: nowrap; } +.upgrade-desc { font-size: 11px; line-height: 1.28; margin-bottom: 7px; } +.buy-button { + color: var(--text); + background: #1a1f24; + border: 1px solid #4b535b; + padding: 6px 8px; + font-size: 11px; +} +.buy-button:not(:disabled):hover, +.tool-button:not(:disabled):hover, +.sort-button:not(:disabled):hover { filter: brightness(1.14); } +.mini-box { + padding: 8px; + border: 1px solid #30363d; + border-radius: 0; + background: #080a0c; + font-size: 11px; + line-height: 1.45; +} +.mini-box strong { color: var(--text); } + +.control-dock { + position: absolute; + z-index: 7; + left: 50%; + bottom: 10px; + transform: translateX(-50%); + display: grid; + grid-template-columns: repeat(4, 150px); + gap: 8px; + padding: 6px; + border: 1px solid #343a40; + background: rgba(8, 10, 12, 0.92); +} +.sort-button { + border: 1px solid #4b535b; + min-height: 46px; + font-size: 12px; + color: #f0f1f2; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025); +} +.sort-button.mixer { + background: #1b2025; +} +.sort-button.truck { + background: #2a2118; + border-color: #705a3c; +} + +.modal { + position: fixed; + inset: 0; + z-index: 20; + display: none; + align-items: center; + justify-content: center; + padding: 22px; + background: rgba(0, 0, 0, 0.72); +} +.modal.visible { display: flex; } +.modal-card { + width: min(720px, 96vw); + border: 1px solid #4b535b; + border-radius: 0; + padding: 22px; + background: #090b0d; + box-shadow: 0 34px 110px rgba(0, 0, 0, 0.78); +} +.modal-card h2 { + font-size: 24px; + margin-bottom: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.modal-card p { color: var(--text); line-height: 1.55; } +.modal-card ul { + margin: 12px 0 0; + padding-left: 22px; + color: var(--muted); + line-height: 1.55; +} +.modal-card strong { color: var(--text); } +.modal-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +@media (max-width: 1100px) { + #app { padding: 0; } + .game-shell { min-height: 100vh; } + #gameCanvas { height: 100vh; min-height: 680px; } + .hud-top-left, + .hud-top-right { grid-template-columns: repeat(2, minmax(74px, auto)); } + .panel-grid { grid-template-columns: 1fr; } + .upgrade-list { grid-template-columns: 1fr; } + .upgrade-panel { max-height: 44vh; overflow: auto; } + .control-dock { grid-template-columns: repeat(2, 150px); } +} + +/* v6 visibility pass: play-first layering */ +.game-shell:not(.phase-build) .upgrade-panel { + display: none; +} +.game-shell.phase-running .hud-top-right { + display: none; +} +.game-shell:not(.phase-running) .control-dock { + display: none; +} +.game-shell.phase-build .upgrade-panel { + bottom: 10px; +} +.game-shell.phase-running .hud-card { + background: rgba(5, 7, 8, 0.84); + border-color: rgba(124, 133, 142, 0.42); +} diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js new file mode 100644 index 0000000..380ab96 --- /dev/null +++ b/src/systems/buildSystem.js @@ -0,0 +1,542 @@ +import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from '../core/config.js'; +import { nextSpawnDelay, getSpawnRange } from '../core/state.js'; +import { createEggFarm, createScanner, createFacility, nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js'; +import { key, parseKey, clamp, pointToCell, cellCenter, distance, yen } from '../core/utils.js'; +import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner } from './routing.js'; +import { spendCash, refundCash } from './economy.js'; +import { snapshot, record } from './history.js'; +import { floating, eraseEffect } from './effects.js'; + +export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { + function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } + + function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; } + + function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; } + function rectsOverlap(a, b, margin = 10) { + return !(a.x + a.w + margin <= b.x || b.x + b.w + margin <= a.x || a.y + a.h + margin <= b.y || b.y + b.h + margin <= a.y); + } + function facilityOverlaps(candidate, ignoreIds = new Set()) { + const rect = rectOfFacility(candidate); + return Object.values(game.facilities).some(f => !ignoreIds.has(f.id) && rectsOverlap(rect, rectOfFacility(f), 12)); + } + + function isEquipmentCell(col, row, moving = null) { + if (!pointInGrid(col, row)) return false; + const k = key(col, row); + const existingFarm = farmAt(game, col, row); + if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true; + const existingScanner = scannerAt(game, col, row); + if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true; + if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true; + return false; + } + + function selectedObject() { + if (!game.selected) return null; + if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null; + if (game.selected.type === 'scanner') return game.scanners.find(s => s.id === game.selected.id) || null; + if (game.selected.type === 'conveyor') return game.conveyorTiles.has(game.selected.id) ? { type: 'conveyor', id: game.selected.id } : null; + if (game.selected.type === 'facility') return game.facilities[game.selected.id] || null; + return null; + } + + function selectedTitle(obj) { + if (!obj) return 'Equipment'; + if (obj.type === 'eggFarm') return `Egg Farm #${obj.id}`; + if (obj.type === 'scanner') return `${obj.kind === 'auto' ? 'Auto' : 'Manual'} Scanner #${obj.id}`; + if (obj.type === 'conveyor') return 'Conveyor Tile'; + if (obj.type === 'facility') return obj.name; + return 'Equipment'; + } + + function equipmentPrice(hit) { + const obj = hit.ref || hit; + const type = hit.type || obj.type; + if (type === 'conveyor') return BUILD_COSTS.conveyor; + if (type === 'eggFarm') return obj.price || BUILD_COSTS.eggFarm; + if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner); + if (type === 'facility') return obj.price || FACILITY_PRICES[obj.id] || 300; + return 0; + } + + function fail(reason) { + ui.buildStatus.textContent = reason; + floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger); + } + + function buildAtCell(cell) { + if (!cell) return fail('Out of grid'); + const { col, row } = cell; + if (game.buildTool === 'conveyor') { + if (game.conveyorTiles.has(key(col, row))) return fail('Cell occupied'); + if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail('Cell occupied'); + if (game.cash < BUILD_COSTS.conveyor) return fail('Not enough cash'); + record(game); + spendCash(game, BUILD_COSTS.conveyor); + const k = key(col, row); + game.conveyorTiles.add(k); + game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: game.buildSession }); + game.selected = { type: 'conveyor', id: k }; + floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.conveyor)}`, THEME.ink); + return; + } + if (isEquipmentCell(col, row)) return fail('Cell occupied'); + if (game.buildTool === 'eggFarm') buildFarm(col, row); + else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual'); + else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto'); + } + + function buildAtPoint(p) { + const cell = pointToCell(p.x, p.y); + if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell); + if (['mixer', 'trash', 'truck'].includes(game.buildTool)) return buildFacility(p, game.buildTool); + } + + function buildFarm(col, row) { + if (game.cash < BUILD_COSTS.eggFarm) return fail('Not enough cash'); + record(game); + spendCash(game, BUILD_COSTS.eggFarm); + const farm = createEggFarm(game, col, row); + game.eggFarms.push(farm); + game.selected = { type: 'eggFarm', id: farm.id }; + floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.eggFarm)}`, THEME.ink); + } + + function buildScanner(col, row, kind) { + const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner; + if (game.cash < cost) return fail('Not enough cash'); + record(game); + spendCash(game, cost); + const scanner = createScanner(game, col, row, kind); + game.scanners.push(scanner); + game.selected = { type: 'scanner', id: scanner.id }; + floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); + } + + function buildFacility(p, id) { + const cost = FACILITY_PRICES[id]; + if (game.facilities[id]) return fail('Facility already exists'); + if (game.cash < cost) return fail('Not enough cash'); + const f = createFacility(game, id, p, cost); + if (facilityOverlaps(f)) return fail('Facility overlap'); + record(game); + spendCash(game, cost); + game.facilities[id] = f; + game.selected = { type: 'facility', id }; + floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink); + } + + function refundForSameBuild(meta, x, y) { + if (!meta || meta.builtSession !== game.buildSession) return; + const amount = meta.price || 0; + if (amount > 0) { + refundCash(game, amount); + floating(game, x, y - 16, `REFUND +${yen(amount)}`, THEME.green); + } + } + + function eraseAtPoint(p) { + const hit = equipmentAtPoint(p); + if (!hit) return fail('Nothing to erase'); + record(game); + if (hit.type === 'conveyor') { + const c = parseKey(hit.oldKey); + const center = cellCenter(c.col, c.row); + refundForSameBuild(game.conveyorMeta.get(hit.oldKey), center.x, center.y); + game.conveyorTiles.delete(hit.oldKey); + game.conveyorMeta.delete(hit.oldKey); + eraseEffect(game, center.x, center.y); + } + if (hit.type === 'eggFarm') { + const c = cellCenter(hit.ref.col, hit.ref.row); + refundForSameBuild(hit.ref, c.x, c.y); + game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id); + eraseEffect(game, c.x, c.y); + } + if (hit.type === 'scanner') { + const c = scannerCenter(hit.ref); + refundForSameBuild(hit.ref, c.x, c.y); + game.scanners = game.scanners.filter(s => s.id !== hit.ref.id); + eraseEffect(game, c.x, c.y); + } + if (hit.type === 'facility') { + const f = hit.ref; + refundForSameBuild(f, f.x + f.w / 2, f.y + f.h / 2); + delete game.facilities[f.id]; + eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2); + } + game.selected = null; + game.multiSelected = []; + updatePanels(); + } + + function equipmentAtPoint(p) { + const f = Object.values(game.facilities).find(x => { + const inBody = p.x >= x.x && p.x <= x.x + x.w && p.y >= x.y && p.y <= x.y + x.h; + if (inBody) return true; + if (!x.entry) return false; + const c = cellCenter(x.entry.col, x.entry.row); + return Math.abs(p.x - c.x) <= GRID.cell * 0.46 && Math.abs(p.y - c.y) <= GRID.cell * 0.46; + }); + if (f) return { type: 'facility', ref: f }; + const cell = pointToCell(p.x, p.y); + if (!cell) return null; + const farm = farmAt(game, cell.col, cell.row); + if (farm) return { type: 'eggFarm', ref: farm }; + const scanner = scannerAt(game, cell.col, cell.row); + if (scanner) return { type: 'scanner', ref: scanner }; + const k = key(cell.col, cell.row); + if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } }; + return null; + } + + function selectionToken(item) { + if (!item) return ''; + if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`; + if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`; + return `${item.type}:${item.id || item.ref?.id}`; + } + + function selectionFromHit(hit) { + if (!hit) return null; + if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; + return { type: hit.type, id: hit.ref.id }; + } + + function hitIsMultiSelected(hit) { + const token = selectionToken(selectionFromHit(hit)); + return (game.multiSelected || []).some(sel => selectionToken(sel) === token); + } + + function resolveSelection(sel) { + if (!sel) return null; + if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null; + if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null; + if (sel.type === 'facility') return game.facilities[sel.id] || null; + if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null; + return null; + } + + function equipmentItems() { + const items = []; + for (const k of game.conveyorTiles) { + const p = parseKey(k); + const c = cellCenter(p.col, p.row); + items.push({ type: 'conveyor', id: k, center: c, cell: p }); + } + for (const farm of game.eggFarms) items.push({ type: 'eggFarm', id: farm.id, ref: farm, center: cellCenter(farm.col, farm.row), cell: { col: farm.col, row: farm.row } }); + for (const scanner of game.scanners) items.push({ type: 'scanner', id: scanner.id, ref: scanner, center: scannerCenter(scanner), cell: { col: scanner.col, row: scanner.row } }); + for (const f of Object.values(game.facilities)) { + const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 }; + const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter; + items.push({ type: 'facility', id: f.id, ref: f, center: bodyCenter, portCenter }); + } + return items; + } + + function pointInsideRect(p, rect) { + const x1 = Math.min(rect.x1, rect.x2), x2 = Math.max(rect.x1, rect.x2); + const y1 = Math.min(rect.y1, rect.y2), y2 = Math.max(rect.y1, rect.y2); + return p.x >= x1 && p.x <= x2 && p.y >= y1 && p.y <= y2; + } + + function selectInRect(rect) { + const selected = []; + for (const item of equipmentItems()) { + if (pointInsideRect(item.center, rect) || (item.portCenter && pointInsideRect(item.portCenter, rect))) { + selected.push({ type: item.type, id: item.id }); + } + } + game.multiSelected = selected; + game.selected = selected[0] || null; + updatePanels(); + return selected.length; + } + + function clearSelection() { + game.multiSelected = []; + game.selected = null; + updatePanels(); + } + + function startSelectionBox(event) { + const p = canvasPoint(event); + game.selectionBox = { x1: p.x, y1: p.y, x2: p.x, y2: p.y }; + game.multiSelected = []; + game.selected = null; + updatePanels(); + return true; + } + + function updateSelectionBox(event) { + if (!game.selectionBox) return; + const p = canvasPoint(event); + game.selectionBox.x2 = p.x; + game.selectionBox.y2 = p.y; + } + + function finishSelectionBox() { + if (!game.selectionBox) return 0; + const box = game.selectionBox; + const w = Math.abs(box.x2 - box.x1), h = Math.abs(box.y2 - box.y1); + game.selectionBox = null; + if (w < 6 && h < 6) { clearSelection(); return 0; } + return selectInRect(box); + } + + function selectedSetForDrag(hit) { + const fromHit = selectionFromHit(hit); + if (!fromHit) return []; + if ((game.multiSelected || []).length && hitIsMultiSelected(hit)) return [...game.multiSelected]; + return [fromHit]; + } + + function selectionOrigin(sel) { + const obj = resolveSelection(sel); + if (!obj) return null; + if (sel.type === 'conveyor') { + const cell = parseKey(sel.id); + return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: BUILD_COSTS.conveyor, builtSession: null }, col: cell.col, row: cell.row }; + } + if (sel.type === 'eggFarm' || sel.type === 'scanner') return { ...sel, obj, col: obj.col, row: obj.row }; + if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } }; + return null; + } + + function startGroupDrag(event) { + const p = canvasPoint(event); + const hit = equipmentAtPoint(p); + if (!hit) return false; + const selections = selectedSetForDrag(hit); + const origins = selections.map(selectionOrigin).filter(Boolean); + if (!origins.length) return false; + game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 }; + game.multiSelected = selections; + game.selected = selections[0] || null; + updatePanels(); + return true; + } + + function commitGroupDragHistory() { + if (!game.groupDrag || game.groupDrag.committed) return; + game.undoStack.push(game.groupDrag.historySnapshot); + game.redoStack = []; + game.groupDrag.committed = true; + } + + function cellOccupiedByNonSelected(col, row, selectedTokens) { + const k = key(col, row); + const farm = farmAt(game, col, row); + if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true; + const scanner = scannerAt(game, col, row); + if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true; + if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true; + return false; + } + + function facilityDragWouldOverlap(dx, dy, selectedTokens) { + const candidateRects = []; + for (const origin of game.groupDrag.origins) { + if (origin.type !== 'facility') continue; + const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; + const { entry, side } = nearestGridEdge(nextCenter); + const draft = { ...origin.obj, entry: { ...entry }, side }; + layoutFacilityOnEdge(draft, entry, side); + candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) }); + } + for (const c of candidateRects) { + for (const f of Object.values(game.facilities)) { + if (selectedTokens.has(`facility:${f.id}`)) continue; + if (rectsOverlap(c.rect, rectOfFacility(f), 12)) return true; + } + } + for (let i = 0; i < candidateRects.length; i += 1) { + for (let j = i + 1; j < candidateRects.length; j += 1) { + if (rectsOverlap(candidateRects[i].rect, candidateRects[j].rect, 12)) return true; + } + } + return false; + } + + function updateGroupDrag(event) { + if (!game.groupDrag) return; + const p = canvasPoint(event); + const dx = p.x - game.groupDrag.start.x; + const dy = p.y - game.groupDrag.start.y; + if (Math.hypot(dx, dy) < 3) return; + const dcol = Math.round(dx / GRID.cell); + const drow = Math.round(dy / GRID.cell); + const selectedTokens = new Set(game.groupDrag.selections.map(selectionToken)); + const targetCells = new Set(); + for (const origin of game.groupDrag.origins) { + if (origin.type === 'facility') continue; + const col = origin.col + dcol, row = origin.row + drow; + if (!pointInGrid(col, row)) return fail('Selection outside grid'); + const tk = key(col, row); + if (targetCells.has(tk)) return fail('Selection overlap'); + if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied'); + targetCells.add(tk); + } + if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap'); + commitGroupDragHistory(); + + const conveyorOrigins = game.groupDrag.origins.filter(o => o.type === 'conveyor'); + for (const o of conveyorOrigins) { + const current = o.currentKey || o.oldKey; + game.conveyorTiles.delete(current); + game.conveyorMeta.delete(current); + } + for (const o of conveyorOrigins) { + const newK = key(o.col + dcol, o.row + drow); + game.conveyorTiles.add(newK); + game.conveyorMeta.set(newK, o.meta || { price: BUILD_COSTS.conveyor, builtSession: null }); + o.currentKey = newK; + } + for (const origin of game.groupDrag.origins) { + if (origin.type === 'eggFarm') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; } + else if (origin.type === 'scanner') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; } + else if (origin.type === 'facility') { + const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; + const { entry, side } = nearestGridEdge(nextCenter); + layoutFacilityOnEdge(origin.obj, entry, side); + } + } + game.groupDrag.selections = game.groupDrag.origins.map(o => o.type === 'conveyor' ? { type: 'conveyor', id: o.currentKey || o.oldKey } : { type: o.type, id: o.id }); + game.multiSelected = [...game.groupDrag.selections]; + game.selected = game.multiSelected[0] || null; + updatePanels(); + } + + function finishGroupDrag() { + if (!game.groupDrag) return; + game.groupDrag = null; + updatePanels(); + } + + function selectedUpgradeCost(obj) { + if (!obj || obj.type === 'conveyor') return null; + if (obj.type === 'eggFarm') return [0, 220, 640, 1500][obj.level] || null; + if (obj.type === 'scanner') return [0, 260, 720, 1600][obj.level] || null; + if (obj.type === 'facility') return [0, 300, 900][obj.level] || null; + return null; + } + + function upgradeSelected() { + const obj = selectedObject(); + const cost = selectedUpgradeCost(obj); + if (!cost) return fail('Max level or no upgrade'); + if (game.cash < cost) return fail('Not enough cash'); + record(game); + spendCash(game, cost); + obj.level += 1; + if (obj.type === 'eggFarm') { + obj.nextSpawn = nextSpawnDelay(obj); + obj.lastInterval = obj.nextSpawn; + } + updatePanels(); + } + + function removeSelected() { + const obj = selectedObject(); + if (!obj) return; + if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id)))); + else if (obj.type === 'eggFarm') eraseAtPoint(cellCenter(obj.col, obj.row)); + else if (obj.type === 'scanner') eraseAtPoint(scannerCenter(obj)); + else if (obj.type === 'facility') eraseAtPoint({ x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 }); + } + + function modalButton(text, onClick, className = 'primary-button') { + const b = document.createElement('button'); + b.type = 'button'; + b.className = className; + b.textContent = text; + b.addEventListener('click', onClick); + return b; + } + + function hideModal() { ui.modal.classList.remove('visible'); } + + function showAutoScannerMenu(scanner) { + ui.modalTitle.textContent = 'Auto Scanner Configuration'; + ui.modalBody.innerHTML = `

Auto scanner types will be split later. Current type is Standard.

`; + ui.modalActions.innerHTML = ''; + const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); }); + const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); }); + const close = modalButton('Close', hideModal); + ui.modalActions.append(r0, r1, close); + ui.modal.classList.add('visible'); + } + + function switchScannerRole() { + const obj = selectedObject(); + if (!obj || obj.type !== 'scanner') return; + if (obj.kind === 'auto') return showAutoScannerMenu(obj); + record(game); + obj.role = obj.role === 0 ? 1 : 0; + updatePanels(); + } + + function setBuildTool(tool) { + if (game.phase !== 'build') return; + game.buildTool = game.buildTool === tool ? null : tool; + game.selected = null; + game.multiSelected = []; + game.selectionBox = null; + updatePanels(); + } + + function equipmentHitBoxes() { + const boxes = []; + for (const [id, f] of Object.entries(game.facilities)) { + boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h }); + if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); } + } + for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); } + for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); } + for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: BUILD_COSTS.conveyor, x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); } + return boxes; + } + + function selectedInfoLines(obj) { + const lines = []; + if (obj.type === 'eggFarm') { + const [min, max] = getSpawnRange(obj); + lines.push(`Price: ${yen(equipmentPrice(obj))}`); + lines.push(`Level: ${obj.level}/4`); + lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`); + } else if (obj.type === 'scanner') { + lines.push(`Price: ${yen(equipmentPrice(obj))}`); + lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`); + lines.push(`Role: ${obj.role === 0 ? 'Male→Left / Others→Right' : 'Poop→Left / Others→Right'}`); + lines.push(`Queue: ${obj.queue.length}`); + if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`); + } else if (obj.type === 'conveyor') { + const meta = game.conveyorMeta.get(obj.id); + const comp = game.componentLookup?.get(obj.id); + const c = comp ? game.congestion.get(comp) : null; + lines.push(`Price: ${yen(BUILD_COSTS.conveyor)}`); + lines.push(`Cell: ${obj.id}`); + lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); + lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.'); + if (meta?.builtSession === game.buildSession) lines.push(`Same-build refund: ${yen(meta.price)}`); + } else if (obj.type === 'facility') { + lines.push(`Price: ${yen(equipmentPrice(obj))}`); + lines.push(`Level: ${obj.level}/3`); + if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`); + } + return lines; + } + + return { + buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint, + startSelectionBox, updateSelectionBox, finishSelectionBox, + startGroupDrag, updateGroupDrag, finishGroupDrag, + selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, + selectedInfoLines, + upgradeSelected, removeSelected, switchScannerRole, + showAutoScannerMenu, setBuildTool, fail, + isEquipmentCell, equipmentHitBoxes, + routeFromFarmToScanner + }; +} diff --git a/src/systems/contracts.js b/src/systems/contracts.js new file mode 100644 index 0000000..94b791b --- /dev/null +++ b/src/systems/contracts.js @@ -0,0 +1,148 @@ +import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TURN } from '../core/config.js'; +import { getSpawnRange } from '../core/state.js'; + +export const CONTRACT_TARGETS = [ + { + target: 'female', + title: 'Premium Female Shipment', + route: 'Female → Truck', + note: 'Only female chicks are valid truck cargo this turn.' + }, + { + target: 'male', + title: 'Male Export Trial', + route: 'Male → Truck', + note: 'Male chicks are temporarily valid truck cargo. Male truck fines are suspended for valid male cargo.' + }, + { + target: 'poop', + title: 'Contaminant Sample Export', + route: 'Poop → Truck', + note: 'Poop is temporarily valid truck cargo. Shipped poop does not soil this contract shipment.' + } +]; + +export function factoryValue(game) { + return [ + ...game.eggFarms.map(f => f.price || 0), + ...game.scanners.map(s => s.price || 0), + ...Object.values(game.facilities).map(f => f.price || 0), + ...[...game.conveyorMeta.values()].map(m => m.price || 0) + ].reduce((a, b) => a + b, 0); +} + +export function estimatedChicksPerTurn(game, productionMultiplier = 1) { + return game.eggFarms.reduce((sum, farm) => { + const [min, max] = getSpawnRange(farm); + const avg = Math.max(0.1, (min + max) / 2); + return sum + (TURN_SECONDS / avg) * productionMultiplier; + }, 0); +} + +export function rollContractOffer(game) { + // No irregular events during the first seven running turns. + // Offers created after Turn 7 apply to Turn 8 or later. + const nextTurn = game.turn + 1; + if (nextTurn < CONTRACT_EVENT_FIRST_TURN) return null; + if (Math.random() > CONTRACT_EVENT_CHANCE) return null; + const template = CONTRACT_TARGETS[Math.floor(Math.random() * CONTRACT_TARGETS.length)]; + const productionMultiplier = 2; + const poopMultiplier = 3; + const estimate = estimatedChicksPerTurn(game, productionMultiplier); + const targetAmount = Math.max(1, Math.ceil(estimate * 0.9)); + const rate = Math.round((0.08 + Math.random() * 0.08) * 100) / 100; + const rewardByFactory = Math.floor(factoryValue(game) * rate); + const rewardByCash = Math.floor(Math.max(0, game.cash) * 0.25); + const reward = Math.max(rewardByFactory, rewardByCash); + return { + id: `contract-${game.turn}-${Date.now()}-${Math.floor(Math.random() * 9999)}`, + ...template, + forced: true, + productionMultiplier, + poopMultiplier, + targetAmount, + estimatedOutput: Math.round(estimate), + rewardRate: rate, + reward, + rewardByFactory, + rewardByCash, + createdTurn: game.turn, + oneTurnOnly: true + }; +} + +export function acceptContract(game) { return !!game.contractOffer; } + +export function declineContract(game) { return false; } + +export function activateAcceptedContract(game) { + // v10.1: limited events are forced. If an event is shown during Build phase, + // it automatically applies to the next turn; there is no accept/skip step. + game.contractActive = game.contractOffer ? { ...game.contractOffer } : null; + game.contractOffer = null; +} + +export function clearActiveContract(game) { + game.contractActive = null; +} + +export function productionMultiplier(game) { + return game.contractActive?.productionMultiplier || 1; +} + +export function currentPoopRate(game) { + return Math.min(0.9, POOP_RATE * (game.contractActive?.poopMultiplier || 1)); +} + +export function truckTarget(game) { + return game.contractActive?.target || 'female'; +} + +export function targetTruckCount(stats, target) { + if (target === 'male') return stats.truckMale || 0; + if (target === 'poop') return stats.poopTruck || 0; + return stats.truckFemale || 0; +} + +export function isTargetTruckCargo(game, sex) { + return truckTarget(game) === sex; +} + +export function isPoopSoilActive(game) { + return truckTarget(game) !== 'poop'; +} + +export function shouldFineMaleTruck(game) { + return truckTarget(game) !== 'male'; +} + +export function resolveContract(game, applyCashDelta) { + const contract = game.contractActive; + if (!contract) return null; + const count = targetTruckCount(game.stats, contract.target); + const success = count >= contract.targetAmount; + const bonus = success ? contract.reward : 0; + if (bonus > 0) { + applyCashDelta(game, bonus); + game.stats.contractBonus = bonus; + game.totals.contractBonus += bonus; + game.totals.contractSuccess += 1; + } else { + game.stats.contractBonus = 0; + game.totals.contractFailed += 1; + } + const result = { + title: contract.title, + target: contract.target, + route: contract.route, + targetAmount: contract.targetAmount, + count, + success, + bonus, + reward: contract.reward, + productionMultiplier: contract.productionMultiplier, + poopMultiplier: contract.poopMultiplier + }; + game.stats.contractResult = result; + return result; +} diff --git a/src/systems/economy.js b/src/systems/economy.js new file mode 100644 index 0000000..1313166 --- /dev/null +++ b/src/systems/economy.js @@ -0,0 +1,59 @@ +import { MIXER_PRICE, TRUCK_PRICE, TRUCK_POOP_DECAY, MIXER_HALF_SECONDS } from '../core/config.js'; +import { factoryValue, targetTruckCount, truckTarget, isPoopSoilActive } from './contracts.js'; + +export function truckPayoutMultiplier(game) { + return Math.max(0, 1 - TRUCK_POOP_DECAY * (isPoopSoilActive(game) ? game.stats.poopTruck : 0)); +} +export function positivePayout(game, amount) { + const base = Math.max(0, Math.floor(amount)); + return game.mixerHalfTimer > 0 ? Math.floor(base / 2) : base; +} +export function maleTruckPenalty(game) { + return Math.floor(30 * (game.turn / 2)); +} +export function applyCashDelta(game, delta) { + game.cash += delta; + game.stats.profit += delta; + game.totals.profit += delta; + if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; } + else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); } + game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); +} +export function spendCash(game, amount) { + game.cash -= amount; + game.stats.profit -= amount; + game.totals.profit -= amount; + game.stats.penalty += amount; + game.totals.penalty += amount; +} +export function refundCash(game, amount) { + if (amount <= 0) return; + game.cash += amount; + game.stats.profit += amount; + game.totals.profit += amount; + game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); +} +export function settleTruckRevenue(game) { + const target = truckTarget(game); + const targetCount = targetTruckCount(game.stats, target); + const base = targetCount * TRUCK_PRICE; + const soilMultiplier = truckPayoutMultiplier(game); + const adjusted = positivePayout(game, Math.floor(base * soilMultiplier)); + game.stats.pendingTruckRevenue = adjusted; + if (adjusted > 0) applyCashDelta(game, adjusted); + return { base, soilMultiplier, adjusted, target, targetCount }; +} +export function finalScore(game) { + const value = factoryValue(game); + const score = Math.max(0, Math.floor( + game.cash + + game.totals.revenue + + value * 0.5 + + game.totals.correct * 5 + + game.totals.contractBonus * 0.5 - + game.totals.explosionDamage * 2 - + game.totals.penalty + )); + return { score, factoryValue: value }; +} +export { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS }; diff --git a/src/systems/effects.js b/src/systems/effects.js new file mode 100644 index 0000000..38c2edc --- /dev/null +++ b/src/systems/effects.js @@ -0,0 +1,93 @@ +import { EFFECT_PRIORITY, THEME } from '../core/config.js'; +import { randomBetween } from '../core/utils.js'; +import { applyCashDelta } from './economy.js'; + +export function floating(game, x, y, text, color = THEME.ink) { + game.floatingTexts.push({ x, y, text, color, life: 1, maxLife: 1 }); +} +export function shake(game, strength, time) { + game.shake.strength = Math.max(game.shake.strength, strength); + game.shake.time = Math.max(game.shake.time, time); +} +export function spawnPulse(game, x, y) { + game.effects.push({ type: 'spawn', priority: EFFECT_PRIORITY.ambient, x, y, life: .42, maxLife: .42, size: 12 }); +} +export function scannerPulse(game, x, y, color = THEME.green) { + game.effects.push({ type: 'scannerPulse', priority: EFFECT_PRIORITY.important, x, y, color, life: .42, maxLife: .42, size: 24 }); +} +export function meatEffect(game, x, y) { + for (let i = 0; i < 12; i += 1) game.effects.push({ type: 'meat', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-60, 60), vy: randomBetween(-90, -25), life: randomBetween(.45, .85), maxLife: .85, size: randomBetween(4, 8) }); +} +export function sludgeEffect(game, x, y) { + for (let i = 0; i < 16; i += 1) game.effects.push({ type: 'sludge', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-46, 46), vy: randomBetween(-70, -20), life: randomBetween(.45, .9), maxLife: .9, size: randomBetween(4, 8) }); +} +export function shredEffect(game, x, y, sex) { + const base = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.male : THEME.female); + for (let i = 0; i < 22; i += 1) game.effects.push({ type: 'shred', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-16, 16), y: y + randomBetween(-8, 8), vx: randomBetween(-70, 70), vy: randomBetween(40, 160), color: i % 3 === 0 ? THEME.white : base, life: randomBetween(.45, .95), maxLife: .95, w: randomBetween(3, 7), h: randomBetween(10, 24), rot: randomBetween(-Math.PI, Math.PI) }); + shockwave(game, x, y, THEME.green, 34, EFFECT_PRIORITY.important); +} +export function truckLoadEffect(game, x, y, sex) { + const color = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.danger : THEME.green); + for (let i = 0; i < 8; i += 1) game.effects.push({ type: 'load', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-18, 18), vx: randomBetween(-42, 42), vy: randomBetween(-90, -25), color, life: randomBetween(.35, .65), maxLife: .65, size: 8 }); +} +export function shockwave(game, x, y, color = THEME.danger, maxSize = 52, priority = EFFECT_PRIORITY.critical) { + game.effects.push({ type: 'shockwave', priority, x, y, color, life: .55, maxLife: .55, size: 8, maxSize }); +} +export function sparkBurst(game, x, y, count = 12, color = THEME.danger, priority = EFFECT_PRIORITY.critical) { + for (let i = 0; i < count; i += 1) { + game.effects.push({ type: 'spark', priority, x, y, vx: randomBetween(-260, 260), vy: randomBetween(-270, 120), life: randomBetween(.35, .9), maxLife: .9, size: randomBetween(3, 8), color }); + } +} +export function smokeBurst(game, x, y, count = 10) { + for (let i = 0; i < count; i += 1) { + game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) }); + } +} +export function flyingDebris(game, sex, x, y, baseAngle = null) { + const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9); + const speed = randomBetween(260, 620); + game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8) }); +} +export function eraseEffect(game, x, y) { + game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 }); + sparkBurst(game, x, y, 8, THEME.ink, EFFECT_PRIORITY.important); +} +export function rageEffect(game, x, y) { + game.effects.push({ type: 'rage', priority: EFFECT_PRIORITY.important, x, y, vx: randomBetween(-12, 12), vy: randomBetween(-45, -10), life: .45, maxLife: .45, size: randomBetween(7, 12), color: THEME.danger }); +} +export function updateEffects(game, dt, canvas, equipmentHitBoxes) { + if (game.shake.time > 0) { + game.shake.time = Math.max(0, game.shake.time - dt); + if (game.shake.time <= 0) game.shake.strength = 0; + } + for (const e of game.effects) { + e.life -= dt; + if (['meat', 'sludge', 'spark', 'smoke', 'shred', 'load', 'rage'].includes(e.type)) { + e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt; + } else if (e.type === 'flyingChick') { + e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt; + handleFlyingChickDamage(game, e, equipmentHitBoxes); + const sx = e.x + game.view.x, sy = e.y + game.view.y; + if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0; + } + } + game.effects = game.effects.filter(e => e.life > 0); + for (const t of game.floatingTexts) { t.life -= dt; t.y -= 34 * dt; } + game.floatingTexts = game.floatingTexts.filter(t => t.life > 0); +} +function handleFlyingChickDamage(game, e, equipmentHitBoxes) { + if (!e.hit) e.hit = new Set(); + for (const box of equipmentHitBoxes()) { + if (e.hit.has(box.id)) continue; + if (e.x < box.x || e.x > box.x + box.w || e.y < box.y || e.y > box.y + box.h) continue; + e.hit.add(box.id); + const damage = Math.max(1, Math.floor(box.price / 15)); + applyCashDelta(game, -damage); + game.stats.explosionDamage += damage; + game.totals.explosionDamage += damage; + floating(game, e.x, e.y - 10, `-${damage}`, THEME.danger); + shockwave(game, e.x, e.y, THEME.danger, 30); + sparkBurst(game, e.x, e.y, 5, THEME.danger); + shake(game, 5, 0.18); + } +} diff --git a/src/systems/history.js b/src/systems/history.js new file mode 100644 index 0000000..d071d2f --- /dev/null +++ b/src/systems/history.js @@ -0,0 +1,59 @@ +export function cleanScanner(scanner) { + return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 }; +} +export function snapshot(game) { + return JSON.stringify({ + cash: game.cash, + stats: game.stats, + totals: game.totals, + nextId: game.nextId, + selected: game.selected, + multiSelected: game.multiSelected || [], + buildTool: game.buildTool, + view: game.view, + facilities: game.facilities, + eggFarms: game.eggFarms, + scanners: game.scanners.map(cleanScanner), + conveyorTiles: [...game.conveyorTiles], + conveyorMeta: [...game.conveyorMeta.entries()], + branchCounters: [...game.branchCounters.entries()] + }); +} +export function restore(game, text) { + const data = JSON.parse(text); + game.cash = data.cash; + game.stats = data.stats; + game.totals = data.totals; + game.nextId = data.nextId; + game.selected = data.selected; + game.multiSelected = data.multiSelected || []; + game.buildTool = data.buildTool; + game.view = data.view || { x: 0, y: 0 }; + game.facilities = data.facilities || {}; + game.eggFarms = data.eggFarms || []; + game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 })); + game.conveyorTiles = new Set(data.conveyorTiles || []); + game.conveyorMeta = new Map(data.conveyorMeta || []); + game.branchCounters = new Map(data.branchCounters || []); + game.groupDrag = null; + game.selectionBox = null; + game.pan = null; +} +export function record(game) { + if (game.phase !== 'build') return; + game.undoStack.push(snapshot(game)); + if (game.undoStack.length > 80) game.undoStack.shift(); + game.redoStack = []; +} +export function undo(game) { + if (game.phase !== 'build' || !game.undoStack.length) return false; + game.redoStack.push(snapshot(game)); + restore(game, game.undoStack.pop()); + return true; +} +export function redo(game) { + if (game.phase !== 'build' || !game.redoStack.length) return false; + game.undoStack.push(snapshot(game)); + restore(game, game.redoStack.pop()); + return true; +} diff --git a/src/systems/routing.js b/src/systems/routing.js new file mode 100644 index 0000000..cc26228 --- /dev/null +++ b/src/systems/routing.js @@ -0,0 +1,270 @@ +import { DIRS, GRID } from '../core/config.js'; +import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js'; + +export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); } +export function scannerConnector(scanner, type) { + return { + inputA: { col: scanner.col, row: scanner.row - 1 }, + // Bottom input is intentionally disabled. Scanners now have one input on top + // and two outputs on the left/right, which makes junction direction easier to read. + inputB: null, + left: { col: scanner.col - 1, row: scanner.row }, + right: { col: scanner.col + 1, row: scanner.row } + }[type]; +} +export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; } +export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; } +export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; } +export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; } + +export function getConveyorNeighbors(game, col, row) { + const result = []; + for (const d of DIRS) { + const nc = col + d.dc, nr = row + d.dr; + if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d }); + } + return result; +} +export function getAdjacentConveyors(game, col, row) { + return DIRS.map(d => ({ col: col + d.dc, row: row + d.dr })) + .filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))); +} +export function getInputScanner(game, col, row) { + return game.scanners.find(scanner => { + const a = scannerConnector(scanner, 'inputA'); + return a && sameCell({ col, row }, a); + }) || null; +} +export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); } +export function isOutputConnector(game, col, row) { + return game.scanners.some(scanner => { + const l = scannerConnector(scanner, 'left'); + const r = scannerConnector(scanner, 'right'); + return sameCell({ col, row }, l) || sameCell({ col, row }, r); + }); +} +export function inputCellsForScanner(game, scanner) { + return ['inputA'].map(t => scannerConnector(scanner, t)) + .filter(p => p && inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))); +} +export function routePoints(cells, extraEnd = null) { + const pts = cells.map(p => cellCenter(p.col, p.row)); + if (extraEnd) pts.push(extraEnd); + return pts; +} +function dirBetween(a, b) { + const dc = b.col - a.col, dr = b.row - a.row; + return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null; +} +function isCross(game, col, row) { + const ns = getConveyorNeighbors(game, col, row); + const names = new Set(ns.map(n => n.dir.name)); + return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down'); +} + +// Routing rule: a four-way cross conveyor is an overpass/crossing. Chicks never turn there. +// The BFS state includes the incoming direction so every route that enters a cross must leave straight. +export function bfsAllRoutes(game, start, isGoal) { + const startK = key(start.col, start.row); + if (!game.conveyorTiles.has(startK)) return []; + const startState = `${startK}|none`; + const queue = [{ col: start.col, row: start.row, incoming: 'none' }]; + const visited = new Set([startState]); + const parent = new Map(); + const found = []; + while (queue.length) { + const cur = queue.shift(); + const curK = key(cur.col, cur.row); + const stateK = `${curK}|${cur.incoming}`; + if (isGoal(cur)) found.push(stateK); + for (const n of getConveyorNeighbors(game, cur.col, cur.row)) { + const outDir = dirBetween(cur, n); + if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue; + const nextStateK = `${key(n.col, n.row)}|${outDir}`; + if (visited.has(nextStateK)) continue; + visited.add(nextStateK); + parent.set(nextStateK, stateK); + queue.push({ col: n.col, row: n.row, incoming: outDir }); + } + } + return found.map(goalState => { + const reversed = []; + let cursor = goalState; + while (cursor) { + const [cellK] = cursor.split('|'); + reversed.push(parseKey(cellK)); + if (cellK === startK) break; + cursor = parent.get(cursor); + } + return reversed.reverse(); + }); +} + +export function chooseRoundRobin(game, id, candidates, advance = false) { + if (!candidates.length) return null; + const sorted = [...candidates].sort((a, b) => { + const ka = a.key || JSON.stringify(a.route || a.cells || a); + const kb = b.key || JSON.stringify(b.route || b.cells || b); + return ka.localeCompare(kb); + }); + const n = game.branchCounters.get(id) || 0; + if (advance) game.branchCounters.set(id, n + 1); + return sorted[n % sorted.length]; +} + +export function scannerOutputs(scanner) { + if (scanner.role === 0) return { left: 'mixer', right: 'scanner-role-1' }; + return { left: 'trash', right: 'truck' }; +} +export function destinationLabel(dest) { + return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase(); +} +export function destinationColor(dest) { + return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015'; +} + +export function facilityEntryPoint(game, dest) { + const f = game.facilities[dest]; + if (!f) return null; + if (f.entry) return cellCenter(f.entry.col, f.entry.row); + // Legacy fallback for older save data. New facilities use fixed receiver cells on the grid edge. + if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 }; + if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 }; + if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 }; + return { x: f.x + f.w / 2, y: f.y + f.h / 2 }; +} +export function facilityEntryCell(game, dest) { + const f = game.facilities[dest]; + return f?.entry || null; +} + +export function facilityEndpointCells(game, dest) { + const f = game.facilities[dest]; + if (!f?.entry) return []; + const entry = f.entry; + const exactKey = key(entry.col, entry.row); + if (game.conveyorTiles.has(exactKey)) return [{ ...entry }]; + // v10.4 connection tolerance: if the receiver port was moved to the grid edge + // but the player left the belt one cell next to it, treat that adjacent belt as + // the endpoint. This keeps moved scanners/conveyors/edge ports from appearing + // visually correct but failing to connect. + const adjacent = []; + for (const d of DIRS) { + const p = { col: entry.col + d.dc, row: entry.row + d.dr }; + if (inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))) adjacent.push(p); + } + return adjacent; +} + +export function isFacilityEndpoint(game, p, dest, connector) { + if (sameCell(p, connector)) return false; + const f = game.facilities[dest]; + // New facilities only route into their explicit receiver port, or one adjacent + // belt tile when the receiver itself has no tile. Arbitrary dead ends never + // auto-become machine exits. + if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e)); + const degree = getConveyorNeighbors(game, p.col, p.row).length; + if (degree > 1) return false; + const entry = facilityEntryPoint(game, dest); + if (!entry) return false; + return distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2; +} + +export function routeFromFarmToScanner(game, farm, advance = false) { + const starts = getAdjacentConveyors(game, farm.col, farm.row).filter(p => !isOutputConnector(game, p.col, p.row)); + const candidates = []; + for (const start of starts) { + const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row)); + for (const cells of routes) { + const last = cells[cells.length - 1]; + const scanner = getInputScanner(game, last.col, last.row); + if (!scanner) continue; + candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells }); + } + } + const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance); + if (!chosen) return null; + return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) }; +} + +export function outputRoute(game, side, fromPoint, scannerId, advance = false) { + const scanner = scannerById(game, scannerId); + if (!scanner) return null; + const connector = scannerConnector(scanner, side); + if (!connector || !game.conveyorTiles.has(key(connector.col, connector.row))) return null; + const dest = scannerOutputs(scanner)[side]; + if (dest === 'scanner-role-1') { + const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length); + const candidates = []; + for (const target of targets) { + const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row))); + const routes = bfsAllRoutes(game, connector, p => inputKeys.has(key(p.col, p.row))); + for (const cells of routes) candidates.push({ key: `s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells }); + } + const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance); + if (!chosen) return null; + return { destination: 'scanner', nextScannerId: chosen.target.id, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, scannerCenter(chosen.target))] }; + } + const routes = bfsAllRoutes(game, connector, p => isFacilityEndpoint(game, p, dest, connector)); + const candidates = routes.map(cells => ({ key: `${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells })); + const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance); + if (!chosen) return null; + return { destination: dest, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, facilityEntryPoint(game, dest))] }; +} + +export function facilityConnectionIssues(game) { + const issues = []; + const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' }; + for (const id of ['mixer', 'trash', 'truck']) { + const f = game.facilities[id]; + if (!f) { + issues.push(`${labels[id]} is missing`); + continue; + } + const endpoints = facilityEndpointCells(game, id); + if (!endpoints.length) issues.push(`${labels[id]} receiver has no conveyor`); + } + return issues; +} + +export function factoryReady(game) { + return facilityConnectionIssues(game).length === 0; +} + +export function buildConveyorComponents(game) { + const components = new Map(); + const cellToComponent = new Map(); + let next = 1; + for (const k of game.conveyorTiles) { + if (cellToComponent.has(k)) continue; + const id = next++; + const queue = [parseKey(k)]; + const cells = []; + cellToComponent.set(k, id); + while (queue.length) { + const cur = queue.shift(); + const ck = key(cur.col, cur.row); + cells.push(ck); + for (const n of getConveyorNeighbors(game, cur.col, cur.row)) { + const nk = key(n.col, n.row); + if (cellToComponent.has(nk)) continue; + cellToComponent.set(nk, id); + queue.push({ col: n.col, row: n.row }); + } + } + components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 }); + } + return { components, cellToComponent }; +} + +export function nearestConveyorKey(game, x, y) { + let best = null; + let bestD = Infinity; + for (const k of game.conveyorTiles) { + const p = parseKey(k); + const c = cellCenter(p.col, p.row); + const d = Math.hypot(c.x - x, c.y - y); + if (d < bestD) { bestD = d; best = k; } + } + return bestD <= GRID.cell * 1.1 ? best : null; +} diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js new file mode 100644 index 0000000..efe1758 --- /dev/null +++ b/src/systems/uiSystem.js @@ -0,0 +1,225 @@ +import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, CONTRACT_EVENT_FIRST_TURN, STARTING_CASH, VERSION } from '../core/config.js'; +import { yen } from '../core/utils.js'; +import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js'; +import { MIXER_PRICE, TRUCK_PRICE, maleTruckPenalty, truckPayoutMultiplier, finalScore } from './economy.js'; +import { productionMultiplier, truckTarget, targetTruckCount, factoryValue } from './contracts.js'; + +export function createUISystem({ game, ui, build, startGame, activeQueuedChick }) { + function conveyorSpeedForTurn(turn) { return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1)); } + function displaySpeed() { return Math.round(conveyorSpeedForTurn(game.phase === 'build' ? game.turn + 1 : game.turn)); } + + function updatePanels() { + updateToolButtons(); + updateBuildStatus(); + updateFacilityPanel(); + updateContractPanel(); + updateTurnSummary(); + updateHistoryButtons(); + } + + function updateToolButtons() { + for (const [name, btn] of Object.entries(ui.buttons)) { + if (btn?.classList?.contains('tool-button')) btn.classList.toggle('active', game.buildTool === name || (name === 'trash' && game.buildTool === 'trash')); + } + } + + function updateBuildStatus() { + ui.buildStatus.textContent = game.phase === 'build' + ? `BUILD: ${(game.buildTool || 'SELECT').toUpperCase()} | Right-drag pan | Left-drag select/move` + : 'Sorting: use scanner keys. Build after clearing the turn.'; + } + + function updateHistoryButtons() { + ui.buttons.undo.disabled = game.phase !== 'build' || game.undoStack.length === 0; + ui.buttons.redo.disabled = game.phase !== 'build' || game.redoStack.length === 0; + } + + function updateTurnSummary() { + const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length; + const issues = facilityConnectionIssues(game); + ui.turnSummary.innerHTML = [ + `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, + `Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`, + issues.length ? `Blocked: ${issues[0]}` : `All receiver ports connected.` + ].join('
'); + } + + function updateContractPanel() { + if (!ui.contractPanel) return; + const offer = game.contractOffer; + if (game.phase !== 'build') { + ui.contractPanel.innerHTML = 'Forced events appear in Build phase.'; + return; + } + if (!offer) { + ui.contractPanel.className = 'contract-card empty'; + const nextTurn = game.turn + 1; + if (nextTurn < CONTRACT_EVENT_FIRST_TURN) { + ui.contractPanel.innerHTML = `Event lock active.No irregular events during Turns 1–7. First possible forced event: Turn ${CONTRACT_EVENT_FIRST_TURN}.`; + } else { + ui.contractPanel.innerHTML = 'No event.Random forced events can appear later.'; + } + return; + } + ui.contractPanel.className = 'contract-card accepted'; + ui.contractPanel.innerHTML = ` +
FORCED NEXT TURN
+

${offer.title}

+
+ Route: ${offer.route} + Target: ${offer.targetAmount} truck exports + Reward: ${yen(offer.reward)} + Output ×${offer.productionMultiplier} / Poop ×${offer.poopMultiplier} +
`; + } + + function updateFacilityPanel() { + const obj = build.selectedObject(); + if (!obj) { + ui.facilityPanel.className = 'facility-panel-empty'; + ui.facilityPanel.innerHTML = 'Click equipment to upgrade, remove, or move it.'; + return; + } + ui.facilityPanel.className = 'facility-card'; + const lines = build.selectedInfoLines(obj); + if (obj.type === 'facility' && obj.id === 'truck') lines.push(`Current truck multiplier: ${Math.round(truckPayoutMultiplier(game) * 100)}%`); + if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.'); + + const actions = []; + const cost = build.selectedUpgradeCost(obj); + if (cost) actions.push(``); + if (obj.type === 'scanner') actions.push(``); + const refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession; + actions.push(``); + + ui.facilityPanel.innerHTML = `

${build.selectedTitle(obj)}

${lines.map(x => `

${x}

`).join('')}
${actions.join('')}
`; + ui.facilityPanel.querySelector('[data-action="upgrade"]')?.addEventListener('click', build.upgradeSelected); + ui.facilityPanel.querySelector('[data-action="role"]')?.addEventListener('click', build.switchScannerRole); + ui.facilityPanel.querySelector('[data-action="remove"]')?.addEventListener('click', build.removeSelected); + } + + function updateUI() { + ui.shell.classList.toggle('phase-running', game.phase === 'running'); + ui.shell.classList.toggle('phase-build', game.phase === 'build'); + ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover'); + ui.shell.classList.toggle('phase-title', game.phase === 'title'); + ui.money.textContent = yen(game.cash); + ui.money.classList.toggle('cash-negative', game.cash < 0); + ui.money.classList.toggle('cash-positive', game.cash > STARTING_CASH); + ui.turn.textContent = game.turn; + ui.timeLeft.textContent = `${Math.max(0, game.timeLeft).toFixed(1)}s`; + ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && game.timeLeft <= 10); + ui.phase.textContent = phaseLabel(); + updatePriorityStrip(); + ui.turnProfit.textContent = yen(game.stats.profit); + ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0); + ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0); + ui.mixerCount.textContent = game.stats.mixerCount; + ui.truckFemaleCount.textContent = game.stats.truckFemale; + ui.truckMaleCount.textContent = game.stats.truckMale; + ui.poopCount.textContent = game.stats.poopSpawned; + ui.buttons.s1Left.disabled = game.phase !== 'running' || !activeQueuedChick(0); + ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0); + ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1); + ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1); + ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game); + } + + function updatePriorityStrip() { + if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return; + const target = truckTarget(game).toUpperCase(); + ui.targetBrief.textContent = `TRUCK TARGET: ${target}`; + ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`; + ui.speedBrief.title = 'Base speed rises by 2% at every new turn.'; + const active = game.contractActive; + const offer = game.contractOffer; + if (active) { + ui.eventBrief.textContent = `ACTIVE EVENT: ${active.route} | Output ×${active.productionMultiplier} | Poop ×${active.poopMultiplier}`; + ui.eventBrief.className = 'event-brief active'; + } else if (game.phase === 'build' && offer) { + ui.eventBrief.textContent = `NEXT TURN EVENT: ${offer.route} | Target ${offer.targetAmount} | Reward ${yen(offer.reward)}`; + ui.eventBrief.className = 'event-brief next'; + } else if (game.phase === 'build' && game.turn + 1 < CONTRACT_EVENT_FIRST_TURN) { + ui.eventBrief.textContent = `NO EVENTS UNTIL TURN ${CONTRACT_EVENT_FIRST_TURN}`; + ui.eventBrief.className = 'event-brief locked'; + } else { + ui.eventBrief.textContent = 'NO ACTIVE EVENT'; + ui.eventBrief.className = 'event-brief'; + } + } + + function phaseLabel() { + if (game.phase === 'running') return game.timeLeft <= 0 ? 'Clearing' : 'Sorting'; + if (game.phase === 'build') return 'Build'; + if (game.phase === 'gameover') return 'Game Over'; + return 'Title'; + } + + function showTitle() { + ui.modalTitle.textContent = `Chick Sorter ${VERSION}`; + ui.modalBody.innerHTML = `
🐤MaleMixerS1: A
🐣FemaleTruckS1: D, then S2: →
💩PoopShredderS1: D, then S2: ←

Build phase: connect all receiver ports before Next Turn.

`; + ui.modalActions.innerHTML = ''; + ui.modalActions.appendChild(button('Start Game', startGame, 'primary-button')); + ui.modal.classList.add('visible'); + } + + function showTurnResult(r) { + ui.modalTitle.textContent = `Turn ${r.turn} Result`; + const contractHtml = r.contract ? ` +
Limited Contract
+
+
Contract${r.contract.title}
+
Rule${r.contract.route}
+
Progress${r.contract.count} / ${r.contract.targetAmount}
+
Bonus${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}
+
` : '
No limited contract active this turn.
'; + ui.modalBody.innerHTML = ` +
Shipment
+
+
Truck target${r.ship.target.toUpperCase()}
+
Target cargo${r.ship.targetCount} × ${yen(TRUCK_PRICE)} = ${yen(r.ship.base)}
+
Poop in truck${r.poopTruck} poop → ${Math.round(r.ship.soilMultiplier * 100)}%
+
Final truck revenue${yen(r.ship.adjusted)}
+
Mixer${r.mixerCount} × ${yen(MIXER_PRICE)}
+
Wrong truck${r.truckMale} males
+
Explosion damage${yen(r.explosionDamage)}
+
Turn profit${yen(r.profit)}
+
Cash${yen(r.cash)}
+
+ ${contractHtml}`; + ui.modalActions.innerHTML = ''; + ui.modalActions.appendChild(button('Build Phase', hideModal, 'primary-button')); + ui.modal.classList.add('visible'); + } + + function showGameOver() { + const processed = game.totals.processed; + const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0; + const fs = finalScore(game); + ui.modalTitle.textContent = 'Game Over'; + ui.modalBody.innerHTML = `

Your cash went negative.

Final Score${fs.score}
Survival${game.totals.turnsCompleted} turns
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Total Penalty${yen(game.totals.penalty)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} truck / ${game.totals.poopMixer} mixer
Explosion Damage${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; + ui.modalActions.innerHTML = ''; + ui.modalActions.appendChild(button('Restart', startGame, 'primary-button')); + ui.modal.classList.add('visible'); + } + + function button(text, onClick, className = 'primary-button') { + const b = document.createElement('button'); + b.type = 'button'; + b.className = className; + b.textContent = text; + b.addEventListener('click', onClick); + return b; + } + + function hideModal() { ui.modal.classList.remove('visible'); } + + function checkGameOver() { + if (game.phase !== 'gameover' && game.cash < 0) { + game.phase = 'gameover'; + showGameOver(); + } + } + + return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver }; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1a36dbe --- /dev/null +++ b/styles.css @@ -0,0 +1,119 @@ +:root { + --bg: #f2fff0; + --screen: #eefbea; + --panel: rgba(245, 255, 242, 0.96); + --ink: #102015; + --muted: #526456; + --line: #102015; + --green: #22b94f; + --green-soft: #b7f5c6; + --white: #ffffff; + --danger: #c42424; + --shadow: 8px 8px 0 rgba(16, 32, 21, 0.22); +} +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; } +button { font: inherit; } +h1, h2, p { margin: 0; } +#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; } +.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); } +#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; } +.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; } +.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); } +.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); } +.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); } +.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); } +.hud-card.primary span { font-size: 10px; } +.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); } +.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); } +.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; } +.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); } +.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; } +.cash-negative { color: var(--danger) !important; } + +.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; } +.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.locked { background: #edf3ec; color: var(--muted); } +.target-brief { color: var(--green); } +.speed-brief { color: var(--ink); } + +.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; } +.panel-head { display: grid; gap: 8px; margin-bottom: 10px; } +.panel-head h1 { font-size: 15px; letter-spacing: .06em; } +.panel-head h1 span { font-size: 10px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; } +.panel-head p { color: var(--muted); margin-top: 4px; font-size: 10px; line-height: 1.25; } +.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } +.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; } +.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; } +.tool-button.small { min-height: 40px; padding: 6px; } +.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); } +.tool-button { min-height: 50px; text-align: left; padding: 8px; } +.tool-button strong { display: block; font-size: 12px; } +.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); } +.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); } +.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); } +.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; } +.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; } +.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; } +.facility-card h3 { color: var(--ink); font-size: 14px; margin: 0 0 6px; } +.facility-card p { margin: 4px 0; } +.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.facility-action { padding: 7px 9px; font-size: 10px; } +.facility-action.good { background: var(--green-soft); } +.facility-action.warn { background: #fff0d1; } +.facility-action.danger { background: #ffdada; } +.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); } +.sort-button { min-height: 46px; font-size: 12px; } +.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); } +.modal.visible { display: flex; } +.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); } +.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; } +.modal-card p { line-height: 1.55; } +.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); } +.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } +.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; } +.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; } +.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; } +.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; } +.route-card b { font-size: 24px; color: var(--green); text-align: center; } +.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; } +.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } +.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; } +.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; } +.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); } +.game-shell:not(.phase-build) .build-panel { display: none; } +.game-shell.phase-running .hud-top-right { display: none; } +.game-shell:not(.phase-running) .control-dock { display: none; } +@media (max-width: 1180px) { + #app { padding: 0; } + .game-shell { min-height: 100vh; } + #gameCanvas { height: 100vh; } + .hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); } + .priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; } + .build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; } + .large-tools { grid-template-columns: 1fr 1fr; } + .control-dock { grid-template-columns: repeat(2, 150px); } + .route-cards, .result-grid { grid-template-columns: 1fr; } +} + +.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); } +.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; } +.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; } +.contract-card p { margin: 5px 0; } +.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.declined { background: #f1f1f1; color: var(--muted); } +.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; } +.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; } +.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); } +.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); } +.result-section-title:first-child { margin-top: 0; } +.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; } + +.compact-rules .mini-box { padding: 8px; font-size: 10px; } +.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }