diff --git a/README.md b/README.md index e4bd3de..90ddd52 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,18 @@ -# Chick Sorter v10.7 UI / Ports / Shutter +# Chick Sorter v11.0 Routing / Compact Upgrades Open `index.html` in a browser. -## v10.7 changes +## v11.0 changes -- Reduced excessive rule/help text. Only essential routing/status is shown. -- Moved the build grid lower so the top rows are not hidden by HUD/status UI. -- Added egg farm shutter animation when the timer reaches 0. Farms stop producing; the remaining chicks must clear before Build phase. -- Facility bodies cannot overlap when building or dragging. -- Mixer / Shredder / Truck receiver ports remain on the grid edge while bodies sit outside the grid. -- Output route arrows are color-coded: - - Mixer: blue - - Shredder: green - - Truck: pink - - If one belt segment carries multiple destination routes, multiple colored guide lines are drawn. Duplicate same-destination lines are collapsed. -- Next Turn is blocked unless Mixer, Shredder, and Truck receiver ports are connected to conveyor. -- Truck visual cargo is cleared before Build phase so females do not remain displayed in the truck after settlement. +- Egg Farm price is now JPY 300. +- Initial S2-to-Truck conveyor lane is longer. +- Egg Farm shutters reopen in Build phase after each day. +- Mixer and Truck upgrades have no level cap. Each upgrade increases income and matching fines by `ceil(base × 1.05^upgrade count)`. +- Manual Scanner and Conveyor upgrade choices are removed. +- Equipment click menu is a compact bubble instead of a large overlay. +- Explosion damage is `ceil(equipment price / 30)`. +- Spawn blocking now highlights the stopped chick instead of showing `WAIT`. +- Scanner/facility routing is more tolerant after manual conveyor dragging. ## Controls diff --git a/assets/images/README.md b/assets/images/README.md index 5f03f73..1bd6e5b 100644 --- a/assets/images/README.md +++ b/assets/images/README.md @@ -4,7 +4,7 @@ The game currently uses simple canvas placeholders. You can override them by add - `chick_male.png` - `chick_female.png` -- `poop.png` +- `tarinai.png` - `conveyor.png` - `scanner_manual.png` - `scanner_auto.png` diff --git a/index.html b/index.html index 7103dc6..44a21b6 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v10.7 UI / Ports / Shutter + Chick Sorter v11.0 Routing / Compact Upgrades @@ -12,21 +12,21 @@
-
CASH¥200
+
CASHJPY 250
TIME60.0s
-
PROFIT¥0
-
TURN1
+
NETJPY 0
+
DAY1
PHASETitle
-
+
MIXER0
TRUCK F0
WRONG0
POOP0
-
+
NO ACTIVE EVENT
TRUCK TARGET: FEMALE
BELT: 52 px/s
@@ -35,32 +35,31 @@
-

CHICK SORTER v10.7

-

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

+

CHICK SORTER v11.0

+

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

- +

Add / Edit Equipment

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

Irregular One-Turn Event

+

Irregular One-Day Event

Irregular forced events appear during Build phase.
@@ -77,10 +76,10 @@
- - - - + + + +
@@ -93,6 +92,6 @@
- + diff --git a/src/README.md b/src/README.md index 1394001..90ddd52 100644 --- a/src/README.md +++ b/src/README.md @@ -1,24 +1,27 @@ -# Chick Sorter v6.0 Visibility Pass +# Chick Sorter v11.0 Routing / Compact Upgrades Open `index.html` in a browser. -## v6 changes +## v11.0 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. +- Egg Farm price is now JPY 300. +- Initial S2-to-Truck conveyor lane is longer. +- Egg Farm shutters reopen in Build phase after each day. +- Mixer and Truck upgrades have no level cap. Each upgrade increases income and matching fines by `ceil(base × 1.05^upgrade count)`. +- Manual Scanner and Conveyor upgrade choices are removed. +- Equipment click menu is a compact bubble instead of a large overlay. +- Explosion damage is `ceil(equipment price / 30)`. +- Spawn blocking now highlights the stopped chick instead of showing `WAIT`. +- Scanner/facility routing is more tolerant after manual conveyor dragging. ## Controls -- Scanner 1: `A` = Mixer, `D` = Truck -- Scanner 2: `←` = Mixer, `→` = Truck +Sorting: +- S1: A = Mixer, D = S2 +- S2: Left Arrow = Shredder, Right Arrow = 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. +Build phase: +- Left drag empty area: box select +- Drag selected equipment: move selection +- Right drag: pan +- Ctrl+Z / Ctrl+Y: undo / redo diff --git a/src/core/config.js b/src/core/config.js index 351a922..0ceaecf 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,7 +1,7 @@ -export const VERSION = 'v10.7 UI / Ports / Shutter'; +export const VERSION = 'v11.0 Routing / Compact Upgrades'; export const TURN_SECONDS = 60; -export const STARTING_CASH = 200; +export const STARTING_CASH = 250; export const MIXER_PRICE = 5; export const TRUCK_PRICE = 10; @@ -9,7 +9,12 @@ 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 CONVEYOR_SPEED_GROWTH = 1.00; +export const CONVEYOR_SPEED_MAX = 300; +export const POOP_FINE = 10; +export const FAIRIES_FEE_PER_DAY = 10; +export const INCOME_UPGRADE_RATE = 1.05; +export const FARM_SHUTDOWN_GRACE_SECONDS = 10; export const AUTO_SCANNER_COOLDOWN = 4.5; export const CONTRACT_EVENT_CHANCE = 0.70; export const CONTRACT_EVENT_FIRST_TURN = 8; @@ -18,7 +23,7 @@ export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 }; export const BUILD_COSTS = { conveyor: 30, - eggFarm: 220, + eggFarm: 300, manualScanner: 260, autoScanner: 360, mixer: 450, diff --git a/src/core/state.js b/src/core/state.js index 8f8d2e0..1c8f67b 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,4 +1,4 @@ -import { STARTING_CASH, TURN_SECONDS, BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js'; +import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js'; import { randomBetween } from './utils.js'; export function newTurnStats() { @@ -16,6 +16,12 @@ export function newTurnStats() { penalty: 0, profit: 0, pendingTruckRevenue: 0, + mixerRevenue: 0, + truckRevenue: 0, + fairiesFee: 0, + mixerPoopFine: 0, + truckPoopFine: 0, + maleTruckFine: 0, explosionDamage: 0, correct: 0, mistakes: 0, @@ -40,6 +46,10 @@ export function newTotalStats() { poopMixer: 0, explosionDamage: 0, contractBonus: 0, + fairiesFee: 0, + mixerPoopFine: 0, + truckPoopFine: 0, + maleTruckFine: 0, contractSuccess: 0, contractFailed: 0 }; @@ -51,6 +61,8 @@ export function createGame() { cash: STARTING_CASH, turn: 1, timeLeft: TURN_SECONDS, + shutdownTimeLeft: 0, + shutdownGraceSeconds: FARM_SHUTDOWN_GRACE_SECONDS, lastTimestamp: 0, nextId: 10, buildTool: null, @@ -117,8 +129,8 @@ function facilityBodyForEntry(id, 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'), + mixer: facilityBodyForEntry('mixer', { col: 0, row: 4 }, 'left'), + trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'), truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right') }; } @@ -127,17 +139,18 @@ 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], + // Egg Farm -> S1 top input. Shorter starter approach lane. + [4, 1], [4, 2], [4, 3], // 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], + [3, 4], [2, 4], [1, 4], [0, 4], + // S1 right output -> S2 top input. + [5, 4], [6, 4], [7, 4], [8, 4], [9, 4], + [9, 5], [10, 5], [11, 5], [11, 6], // S2 left output -> Waste Shredder receiver on bottom grid edge. - [16, 7], [16, 8], [16, 9], [16, 10], [16, 11], [16, 12], + [10, 7], [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], // S2 right output -> Truck receiver on the right grid edge. - [18, 7], [19, 7], [20, 7], [21, 7] + // Restored a longer lane so S2-to-truck transport is readable and less congested. + [12, 7], [13, 7], [14, 7], [15, 7], [16, 7], [17, 7], [18, 7], [19, 7], [20, 7], [21, 7] ]; for (const [col, row] of initial) { const k = `${col},${row}`; game.conveyorTiles.add(k); @@ -145,8 +158,8 @@ export function resetLayout(game) { } 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' } + { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 4, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }, + { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, 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); diff --git a/src/core/utils.js b/src/core/utils.js index c726dfc..6bc549b 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -1,6 +1,6 @@ import { GRID } from './config.js'; -export function yen(value) { return `¥${Math.floor(value).toLocaleString('en-US')}`; } +export function yen(value) { return `JPY ${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)); } diff --git a/src/game.js b/src/game.js index e75fc2f..94fd80e 100644 --- a/src/game.js +++ b/src/game.js @@ -1,15 +1,15 @@ -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 { TURN_SECONDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, THEME } from './core/config.js'; +import { createGame, resetLayout, newTurnStats } from './core/state.js'; +import { clamp, pointToCell, cellCenter } from './core/utils.js'; +import { facilityConnectionIssues } from './systems/routing.js'; +import { applyCashDelta, collectFairiesFee, settleTruckRevenue } from './systems/economy.js'; +import { 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'; +import { createChickSystem } from './systems/chickSystem.js'; +import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; +import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); @@ -30,18 +30,17 @@ const ui = { const game = createGame(); let build; let uiSystem; +let chicks; -function conveyorSpeedForTurn(turn) { - return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1)); -} -function currentConveyorSpeed() { return conveyorSpeedForTurn(game.turn); } +function currentConveyorSpeed() { return Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED); } function startGame() { Object.assign(game, createGame()); game.phase = 'running'; + game.view = { x: 0, y: 0 }; resetLayout(game); uiSystem.hideModal(); - updateCongestion(); + chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); } @@ -58,17 +57,22 @@ function startNextTurn() { game.phase = 'running'; game.turn += 1; game.timeLeft = TURN_SECONDS; + game.shutdownTimeLeft = 0; game.chicks = []; game.effects = []; game.floatingTexts = []; game.shake = { time: 0, strength: 0 }; game.truckCargo = []; + game.view = { x: 0, y: 0 }; 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; } + for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; } game.buildTool = null; game.selected = null; - updateCongestion(); + game.groupDrag = null; + game.selectionBox = null; + game.pan = null; + chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); } @@ -77,17 +81,21 @@ function completeTurn() { if (game.phase !== 'running') return; const ship = settleTruckRevenue(game); const contract = resolveContract(game, applyCashDelta); + const fairiesFee = collectFairiesFee(game); clearActiveContract(game); + game.totals.turnsCompleted += 1; + game.lastResult = { ...game.stats, ship, contract, fairiesFee, turn: game.turn, cash: game.cash }; 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.chicks = []; + game.shutdownTimeLeft = 0; game.buildSession += 1; game.undoStack = []; game.redoStack = []; game.timeLeft = 0; - game.totals.turnsCompleted += 1; for (const scanner of game.scanners) scanner.queue = []; + for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; } game.contractOffer = rollContractOffer(game); game.buildTool = null; game.groupDrag = null; @@ -98,273 +106,30 @@ function completeTurn() { } function closeFarmShutters() { + if (game.shutdownTimeLeft <= 0) game.shutdownTimeLeft = game.shutdownGraceSeconds || 10; for (const farm of game.eggFarms) { + if (farm.shutterSparked) continue; 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); + floating(game, c.x, c.y - 34, 'SHUT +10s', 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); + if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn }); updateEffects(game, dt, canvas, build.equipmentHitBoxes); - updateCongestion(); - drawAll(ctx, canvas, game, { activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle }); + chicks.updateCongestion(); + drawAll(ctx, canvas, game, { activeQueuedChick: chicks.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. @@ -380,7 +145,7 @@ function clickSelect(event) { game.selected = sel; game.multiSelected = [sel]; uiSystem.updatePanels(); - if (hit.type === 'scanner' && hit.ref.kind === 'auto') build.showAutoScannerMenu(hit.ref); + build.showSelectedMenu(); } canvas.addEventListener('contextmenu', event => event.preventDefault()); @@ -391,7 +156,12 @@ canvas.addEventListener('pointerdown', 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 (game.buildTool) { + const hit = build.equipmentAtPoint(world); + if (hit) { game.buildTool = null; clickSelect(event); return; } + build.buildAtPoint(world); + return; + } if (build.startGroupDrag(event)) return; build.startSelectionBox(event); }); @@ -414,11 +184,12 @@ canvas.addEventListener('pointerup', event => { }); 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.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.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; } }); +window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.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); +chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); +uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick: chicks.activeQueuedChick }); +resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/index.html b/src/index.html index cfd5067..f6bcf63 100644 --- a/src/index.html +++ b/src/index.html @@ -3,67 +3,83 @@ - Chick Sorter v6.0 Visibility Pass + Chick Sorter v11.0 Routing / Compact Upgrades
- +
-
CASH¥140
+
CASHJPY 250
TIME60.0s
-
PROFIT¥0
-
TURN1
+
NETJPY 0
+
DAY1
PHASETitle
-
+
MIXER0
TRUCK F0
WRONG0
-
RANDOM0
+
POOP0
-
+
+
NO ACTIVE EVENT
+
TRUCK TARGET: FEMALE
+
BELT: 52 px/s
+
+ +
-

CHICK SORTER v6.0

-

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

+

CHICK SORTER v11.0

+

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

- +
-
-

Build

-
- - - - +
+

Add / Edit Equipment

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

Upgrades

-
+
+

Irregular One-Day Event

+
Irregular forced events appear during Build phase.
-
-

Live Rules

+
+

Selected Facility

+
Click equipment in Build phase.
+
+ +
+

Status

- - - - + + + +
@@ -76,6 +92,6 @@
- + diff --git a/src/render/draw.js b/src/render/draw.js index 4c3b70d..cf85657 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -1,12 +1,12 @@ 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'; +import { upgradedTruckPrice } from '../systems/economy.js'; const ASSET_PATHS = { chickMale: './assets/images/chick_male.png', chickFemale: './assets/images/chick_female.png', - poop: './assets/images/poop.png', + poop: './assets/images/tarinai.png', conveyor: './assets/images/conveyor.png', scannerManual: './assets/images/scanner_manual.png', scannerAuto: './assets/images/scanner_auto.png', @@ -187,12 +187,12 @@ function drawDestinationFlowSegments(ctx, segments) { 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); + const offset = (index - (count - 1) / 2) * 12; + drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset, index - (count - 1) / 2); }); } } -function drawColoredFlowSegment(ctx, a, b, color, offset) { +function drawColoredFlowSegment(ctx, a, b, color, offset, arrowShift = 0) { const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.hypot(dx, dy); if (dist < 8) return; @@ -203,7 +203,7 @@ function drawColoredFlowSegment(ctx, a, b, color, offset) { 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)); + if (dist > 32) drawArrow(ctx, ax + (bx - ax) * .58 + arrowShift * 9, ay + (by - ay) * .58, Math.atan2(dy, dx)); ctx.restore(); } function drawArrow(ctx, x, y, angle) { @@ -387,7 +387,7 @@ function drawTruck(ctx, game) { 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); + ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${upgradedTruckPrice(game)}円`, 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); @@ -409,6 +409,15 @@ function drawChick(ctx, chick, active, game) { 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.stoppedTimer > 0) { + ctx.strokeStyle = THEME.warn; + ctx.lineWidth = 4; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.arc(chick.x, y, chick.radius + 10, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius); else { const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale; @@ -420,6 +429,15 @@ function drawChick(ctx, chick, active, game) { 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(); } } + if (chick.stoppedTimer > 0) { + ctx.font = '900 10px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.lineWidth = 4; + ctx.strokeStyle = THEME.white; + ctx.fillStyle = THEME.warn; + ctx.strokeText('STOPPED', chick.x, y - chick.radius - 13); + ctx.fillText('STOPPED', chick.x, y - chick.radius - 13); + } ctx.restore(); } function nearestRatio(game, chick) { diff --git a/src/styles.css b/src/styles.css index 5efcfbd..dd3ec8c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -350,3 +350,64 @@ h1, h2, p { margin: 0; } background: rgba(5, 7, 8, 0.84); border-color: rgba(124, 133, 142, 0.42); } +.formula-box { + border: 1px solid #30363d; + background: #101418; + padding: 10px; + margin: 10px 0; + line-height: 1.5; + color: var(--text); + font-size: 12px; +} +.equipment-menu-lines { + border: 1px solid #30363d; + background: #080a0c; + padding: 10px; + margin: 8px 0; + color: var(--muted); + line-height: 1.45; +} +.equipment-menu-lines p { margin: 4px 0; } + +/* v11 compact clicked-equipment bubble */ +.modal.equipment-popover { + background: transparent; + align-items: flex-start; + justify-content: flex-start; + padding: 0; + pointer-events: none; +} +.modal.equipment-popover .modal-card { + position: absolute; + left: var(--popover-x, 16px); + top: var(--popover-y, 16px); + width: min(320px, calc(100vw - 24px)); + max-height: min(360px, calc(100vh - 24px)); + overflow: auto; + padding: 10px; + pointer-events: auto; + box-shadow: 6px 6px 0 rgba(0,0,0,.28); +} +.modal.equipment-popover .modal-card h2 { + font-size: 14px; + margin-bottom: 6px; +} +.modal.equipment-popover .modal-actions { + margin-top: 8px; + gap: 6px; + justify-content: flex-start; +} +.modal.equipment-popover .modal-actions button { + padding: 7px 9px; + font-size: 10px; +} +.equipment-menu-lines.compact { + padding: 7px; + margin: 6px 0; + font-size: 10px; +} +.formula-box.compact { + padding: 7px; + margin: 6px 0; + font-size: 10px; +} diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 380ab96..4f8beb1 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,11 +1,12 @@ 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 { createEggFarm, createScanner, createFacility } from '../core/entities.js'; +import { key, parseKey, pointToCell, cellCenter, 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 { incomeMultiplier, mixerPoopPenalty, spendCash, refundCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice } from './economy.js'; +import { record } from './history.js'; import { floating, eraseEffect } from './effects.js'; +import { createSelectionSystem } from './selectionSystem.js'; export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } @@ -191,241 +192,23 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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(); - } + const selection = createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }); 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; + if (obj.type === 'eggFarm') return [0, 300, 720, 1600][obj.level] || null; + if (obj.type === 'scanner') return null; + if (obj.type === 'facility' && ['mixer', 'truck'].includes(obj.id)) { + if ((obj.level || 1) <= 1) return 300; + return Math.ceil(900 * Math.pow(1.65, (obj.level || 1) - 2)); + } return null; } function upgradeSelected() { const obj = selectedObject(); const cost = selectedUpgradeCost(obj); - if (!cost) return fail('Max level or no upgrade'); + if (!cost) return fail('No upgrade available'); if (game.cash < cost) return fail('Not enough cash'); record(game); spendCash(game, cost); @@ -455,7 +238,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return b; } - function hideModal() { ui.modal.classList.remove('visible'); } + function hideModal() { + ui.modal.classList.remove('visible', 'equipment-popover'); + ui.modal.style.removeProperty('--popover-x'); + ui.modal.style.removeProperty('--popover-y'); + } function showAutoScannerMenu(scanner) { ui.modalTitle.textContent = 'Auto Scanner Configuration'; @@ -465,6 +252,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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.remove('equipment-popover'); ui.modal.classList.add('visible'); } @@ -472,11 +260,48 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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 selectedPopoverWorldPoint(obj) { + if (!obj) return { x: 80, y: 80 }; + if (obj.type === 'conveyor') { const p = parseKey(obj.id); return cellCenter(p.col, p.row); } + if (obj.type === 'eggFarm') return cellCenter(obj.col, obj.row); + if (obj.type === 'scanner') return scannerCenter(obj); + if (obj.type === 'facility') return { x: obj.x + obj.w / 2, y: obj.y + 10 }; + return { x: 80, y: 80 }; + } + + function positionEquipmentPopover(obj) { + const rect = canvas.getBoundingClientRect(); + const p = selectedPopoverWorldPoint(obj); + const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18; + const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12; + const maxX = Math.max(12, window.innerWidth - 344); + const maxY = Math.max(12, window.innerHeight - 260); + ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`); + ui.modal.style.setProperty('--popover-y', `${Math.max(12, Math.min(maxY, screenY))}px`); + } + + function showSelectedMenu() { + const obj = selectedObject(); + if (!obj) return; + const lines = selectedInfoLines(obj); + ui.modalTitle.textContent = selectedTitle(obj); + ui.modalBody.innerHTML = ` +
${lines.map(x => `

${x}

`).join('')}
+ ${['mixer', 'truck'].includes(obj.id) ? '

Income = ceil(base × 1.05^upgrade count). Fines use the same multiplier.

' : ''}`; + ui.modalActions.innerHTML = ''; + const cost = selectedUpgradeCost(obj); + if (cost) ui.modalActions.appendChild(modalButton(`Upgrade ${yen(cost)}`, () => { upgradeSelected(); hideModal(); }, 'primary-button')); + if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn')); + const refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession; + ui.modalActions.appendChild(modalButton(`Remove${refundable ? ' / Refund' : ''}`, () => { removeSelected(); hideModal(); }, 'facility-action danger')); + ui.modalActions.appendChild(modalButton('Close', hideModal)); + positionEquipmentPopover(obj); + ui.modal.classList.add('visible', 'equipment-popover'); + } + function setBuildTool(tool) { if (game.phase !== 'build') return; game.buildTool = game.buildTool === tool ? null : tool; @@ -508,9 +333,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } 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(`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`); + if (obj.kind === 'manual') lines.push('Upgrade: removed'); } else if (obj.type === 'conveyor') { const meta = game.conveyorMeta.get(obj.id); const comp = game.componentLookup?.get(obj.id); @@ -522,7 +348,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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`); + lines.push(['mixer', 'truck'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`); + if (obj.id === 'mixer') { + lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`); + lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`); + } + if (obj.id === 'truck') { + lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`); + lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`); + } if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`); } return lines; @@ -530,12 +364,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return { buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint, - startSelectionBox, updateSelectionBox, finishSelectionBox, - startGroupDrag, updateGroupDrag, finishGroupDrag, + startSelectionBox: selection.startSelectionBox, + updateSelectionBox: selection.updateSelectionBox, + finishSelectionBox: selection.finishSelectionBox, + startGroupDrag: selection.startGroupDrag, + updateGroupDrag: selection.updateGroupDrag, + finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, selectedInfoLines, upgradeSelected, removeSelected, switchScannerRole, - showAutoScannerMenu, setBuildTool, fail, + showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, routeFromFarmToScanner }; diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js new file mode 100644 index 0000000..b236d04 --- /dev/null +++ b/src/systems/chickSystem.js @@ -0,0 +1,436 @@ +import { AUTO_SCANNER_COOLDOWN, GRID, THEME } from '../core/config.js'; +import { createChick } from '../core/entities.js'; +import { nextSpawnDelay } from '../core/state.js'; +import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; +import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; +import { maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, applyCashDelta } from './economy.js'; +import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; +import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js'; +import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js'; + +export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) { + function currentSpawnDelay(farm) { + return nextSpawnDelay(farm) / productionMultiplier(game); + } + + function spawnChick(farm) { + const data = routeFromFarmToScanner(game, farm, true); + const farmCenter = cellCenter(farm.col, farm.row); + if (!data) { + floating(game, farmCenter.x, farmCenter.y - 18, 'NO BELT', THEME.danger); + return false; + } + const start = data.route[0]; + const blocker = spawnTileBlocker(start); + if (blocker) { + blocker.stoppedTimer = 0.85; + return false; + } + const chick = createChick(game, data); + if (chick.sex === 'poop') countPoopSpawned(game); + game.chicks.push(chick); + spawnPulse(game, farmCenter.x, farmCenter.y); + return true; + } + + function spawnTileBlocker(start) { + return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null; + } + + function updateRunning(dt, { closeFarmShutters, completeTurn }) { + const producing = game.timeLeft > 0; + game.timeLeft = Math.max(0, game.timeLeft - dt); + if (producing && game.timeLeft <= 0) closeFarmShutters(); + if (game.timeLeft <= 0) { + if (game.shutdownTimeLeft > 0) game.shutdownTimeLeft = Math.max(0, game.shutdownTimeLeft - dt); + 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) { + const spawned = spawnChick(farm); + farm.nextSpawn = spawned ? currentSpawnDelay(farm) : 0.15; + 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.stoppedTimer > 0) chick.stoppedTimer = Math.max(0, chick.stoppedTimer - dt); + if (chick.stage === 'queued') continue; + updateRoutedChick(chick, i, dt); + } + updateScannerQueues(dt); + updateCongestion(); + checkCongestionExplosions(); + if (game.timeLeft <= 0 && (game.chicks.length === 0 || game.shutdownTimeLeft <= 0)) completeTurn(); + onGameOverCheck(); + } + + function updateRoutedChick(chick, index, dt) { + if (blockedByFrontChick(chick)) { + chick.stoppedTimer = 0.35; + 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; + countAutoSorted(game); + } + } + } + + 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 takeChick(index) { + const chick = game.chicks[index]; + if (!chick) return null; + game.chicks.splice(index, 1); + return chick; + } + + function resolveMixer(index) { + const chick = takeChick(index); + if (!chick) return; + const { x, y } = chick; + countMixer(game); + if (chick.sex === 'poop') { + const penalty = mixerPoopPenalty(game); + countPoopDestination(game, 'mixer'); + countMistake(game); + game.stats.mixerPoopFine += penalty; + game.totals.mixerPoopFine += penalty; + applyCashDelta(game, -penalty); + sludgeEffect(game, x, y); + floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); + onGameOverCheck(); + return; + } + const amount = upgradedMixerPrice(game); + game.stats.mixerRevenue += amount; + applyCashDelta(game, amount); + if (chick.sex === 'male') countCorrect(game); + else countMistake(game); + meatEffect(game, x, y); + floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); + onGameOverCheck(); + } + + function resolveTruck(index) { + const chick = takeChick(index); + if (!chick) return; + const { x, y } = chick; + addTruckCargo(chick.sex); + truckLoadEffect(game, x, y, chick.sex); + countTruckCargo(game, chick.sex); + const target = truckTarget(game); + const isTarget = isTargetTruckCargo(game, chick.sex); + if (chick.sex === 'poop') { + const penalty = truckPoopPenalty(game); + countPoopDestination(game, 'truck'); + if (isTarget) countCorrect(game); + else countMistake(game); + game.stats.truckPoopFine += penalty; + game.totals.truckPoopFine += penalty; + applyCashDelta(game, -penalty); + floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); + onGameOverCheck(); + return; + } + if (chick.sex === 'female') { + if (isTarget) { + countCorrect(game); + floating(game, x, y - 18, `+${yen(upgradedTruckPrice(game))}`, THEME.green); + } else { + countMistake(game); + floating(game, x, y - 18, target === 'male' ? 'REJECT' : 'NO SALE', THEME.warn); + } + return; + } + if (isTarget) { + countCorrect(game); + floating(game, x, y - 18, `+${yen(upgradedTruckPrice(game))}`, THEME.green); + return; + } + if (shouldFineMaleTruck(game)) { + const penalty = maleTruckPenalty(game); + game.stats.maleTruckFine += penalty; + game.totals.maleTruckFine += penalty; + applyCashDelta(game, -penalty); + countMistake(game); + floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); + onGameOverCheck(); + } else { + countMistake(game); + floating(game, x, y - 18, 'REJECT', THEME.warn); + } + } + + function resolveTrash(index) { + const chick = takeChick(index); + if (!chick) return; + const { x, y } = chick; + shredEffect(game, x, y, chick.sex); + countTrash(game); + if (chick.sex === 'poop') { + countPoopDestination(game, 'trash'); + countCorrect(game); + floating(game, x, y - 18, 'CLEAN', THEME.green); + } else { + countMistake(game); + floating(game, x, y - 18, 'WASTE', THEME.ink); + } + } + + function removeChick(index, label) { + const chick = takeChick(index); + if (!chick) return; + 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; + } + 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() { + 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); + } + + return { + activeQueuedChick, + currentSpawnDelay, + sortSlot, + updateCongestion, + updateRunning + }; +} diff --git a/src/systems/contracts.js b/src/systems/contracts.js index 94b791b..6006f14 100644 --- a/src/systems/contracts.js +++ b/src/systems/contracts.js @@ -5,19 +5,19 @@ export const CONTRACT_TARGETS = [ { target: 'female', title: 'Premium Female Shipment', - route: 'Female → Truck', + route: 'Female -> Truck', note: 'Only female chicks are valid truck cargo this turn.' }, { target: 'male', title: 'Male Export Trial', - route: 'Male → Truck', + 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', + route: 'Poop -> Truck', note: 'Poop is temporarily valid truck cargo. Shipped poop does not soil this contract shipment.' } ]; @@ -71,10 +71,6 @@ export function rollContractOffer(game) { }; } -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. diff --git a/src/systems/economy.js b/src/systems/economy.js index 1313166..c5c66ac 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -1,16 +1,43 @@ -import { MIXER_PRICE, TRUCK_PRICE, TRUCK_POOP_DECAY, MIXER_HALF_SECONDS } from '../core/config.js'; -import { factoryValue, targetTruckCount, truckTarget, isPoopSoilActive } from './contracts.js'; +import { FAIRIES_FEE_PER_DAY, INCOME_UPGRADE_RATE, MIXER_PRICE, POOP_FINE, TRUCK_PRICE } from '../core/config.js'; +import { factoryValue, targetTruckCount, truckTarget } from './contracts.js'; -export function truckPayoutMultiplier(game) { - return Math.max(0, 1 - TRUCK_POOP_DECAY * (isPoopSoilActive(game) ? game.stats.poopTruck : 0)); +export function facilityUpgradeCount(game, id) { + return Math.max(0, (game.facilities?.[id]?.level || 1) - 1); } -export function positivePayout(game, amount) { - const base = Math.max(0, Math.floor(amount)); - return game.mixerHalfTimer > 0 ? Math.floor(base / 2) : base; + +export function incomeMultiplier(game, id) { + return Math.pow(INCOME_UPGRADE_RATE, facilityUpgradeCount(game, id)); } + +export function upgradedMixerPrice(game) { + return Math.ceil(MIXER_PRICE * incomeMultiplier(game, 'mixer')); +} + +export function upgradedTruckPrice(game) { + return Math.ceil(TRUCK_PRICE * incomeMultiplier(game, 'truck')); +} + +export function mixerPoopPenalty(game) { + return Math.ceil(POOP_FINE * incomeMultiplier(game, 'mixer')); +} + +export function truckPoopPenalty(game) { + return Math.ceil(POOP_FINE * incomeMultiplier(game, 'truck')); +} + export function maleTruckPenalty(game) { - return Math.floor(30 * (game.turn / 2)); + const baseFine = 30 * (game.turn / 2); + return Math.ceil(baseFine * incomeMultiplier(game, 'truck')); } + +export function dailyFairiesFee(game) { + return Math.ceil(Math.max(1, game.turn) * FAIRIES_FEE_PER_DAY); +} + +export function positivePayout(_game, amount) { + return Math.max(0, Math.ceil(amount)); +} + export function applyCashDelta(game, delta) { game.cash += delta; game.stats.profit += delta; @@ -19,6 +46,7 @@ export function applyCashDelta(game, 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; @@ -26,6 +54,7 @@ export function spendCash(game, amount) { game.stats.penalty += amount; game.totals.penalty += amount; } + export function refundCash(game, amount) { if (amount <= 0) return; game.cash += amount; @@ -33,27 +62,43 @@ export function refundCash(game, 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)); + const unitPrice = upgradedTruckPrice(game); + const base = targetCount * unitPrice; + const adjusted = positivePayout(game, base); game.stats.pendingTruckRevenue = adjusted; + game.stats.truckRevenue = adjusted; if (adjusted > 0) applyCashDelta(game, adjusted); - return { base, soilMultiplier, adjusted, target, targetCount }; + return { base, adjusted, target, targetCount, unitPrice }; } + +export function collectFairiesFee(game) { + const fee = dailyFairiesFee(game); + game.stats.fairiesFee += fee; + game.totals.fairiesFee += fee; + applyCashDelta(game, -fee); + return fee; +} + export function finalScore(game) { const value = factoryValue(game); + const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1); + const base = Math.max(0, game.cash + game.totals.revenue + value * 0.5 + game.totals.contractBonus * 0.5); + const dailyEarned = Math.ceil(base / days); + const dayPenalty = Math.floor(days * 35 + Math.max(0, days - 5) * 15); + const correctBonus = game.totals.correct * 5; + const explosionPenalty = game.totals.explosionDamage; 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 + base + + dailyEarned + + correctBonus - + game.totals.penalty - + dayPenalty )); - return { score, factoryValue: value }; + return { score, factoryValue: value, base, dailyEarned, dayPenalty, correctBonus, explosionPenalty, days }; } -export { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS }; + +export { MIXER_PRICE, TRUCK_PRICE }; diff --git a/src/systems/effects.js b/src/systems/effects.js index 38c2edc..22c0c06 100644 --- a/src/systems/effects.js +++ b/src/systems/effects.js @@ -81,7 +81,7 @@ function handleFlyingChickDamage(game, e, 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)); + const damage = Math.max(1, Math.ceil(box.price / 30)); applyCashDelta(game, -damage); game.stats.explosionDamage += damage; game.totals.explosionDamage += damage; diff --git a/src/systems/routing.js b/src/systems/routing.js index cc26228..43f115d 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -29,11 +29,31 @@ 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))); } +function conveyorCellsAdjacentTo(game, point, blocked = []) { + if (!point) return []; + const cells = []; + for (const d of DIRS) { + const p = { col: point.col + d.dc, row: point.row + d.dr }; + if (!inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) continue; + if (blocked.some(b => b && sameCell(p, b))) continue; + cells.push(p); + } + return cells; +} + +export function scannerInputCells(game, scanner) { + const input = scannerConnector(scanner, 'inputA'); + if (!input || !inGrid(input.col, input.row)) return []; + const exactKey = key(input.col, input.row); + if (game.conveyorTiles.has(exactKey)) return [{ ...input }]; + // Tolerance pass: after manual drag edits, accept belts immediately touching + // the intended input port. This prevents visually connected top inputs from + // failing only because the exact port cell was moved out from under the belt. + return conveyorCellsAdjacentTo(game, input, [scannerConnector(scanner, 'left'), scannerConnector(scanner, 'right')]); +} + export function getInputScanner(game, col, row) { - return game.scanners.find(scanner => { - const a = scannerConnector(scanner, 'inputA'); - return a && sameCell({ col, row }, a); - }) || null; + return game.scanners.find(scanner => scannerInputCells(game, scanner).some(p => sameCell({ col, row }, p))) || null; } export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); } export function isOutputConnector(game, col, row) { @@ -44,8 +64,32 @@ export function isOutputConnector(game, col, row) { }); } 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))); + return scannerInputCells(game, scanner); +} + +function outputStartCells(game, scanner, side) { + const connector = scannerConnector(scanner, side); + if (!connector || !inGrid(connector.col, connector.row)) return []; + const exactKey = key(connector.col, connector.row); + if (game.conveyorTiles.has(exactKey)) return [{ ...connector, viaTolerance: false }]; + const outward = side === 'left' ? { dc: -1, dr: 0 } : { dc: 1, dr: 0 }; + const preferred = { col: connector.col + outward.dc, row: connector.row + outward.dr }; + const candidates = []; + if (inGrid(preferred.col, preferred.row) && game.conveyorTiles.has(key(preferred.col, preferred.row))) candidates.push({ ...preferred, viaTolerance: true }); + for (const p of conveyorCellsAdjacentTo(game, connector, [scannerConnector(scanner, 'inputA')])) { + if (!candidates.some(c => sameCell(c, p))) candidates.push({ ...p, viaTolerance: true }); + } + return candidates; +} + +function routeWithConnector(fromPoint, connector, cells, extraEnd = null) { + const points = [{ x: fromPoint.x, y: fromPoint.y }]; + const connectorPoint = cellCenter(connector.col, connector.row); + if (distance(points[points.length - 1], connectorPoint) > 1) points.push(connectorPoint); + for (const p of routePoints(cells, extraEnd)) { + if (distance(points[points.length - 1], p) > 1) points.push(p); + } + return points; } export function routePoints(cells, extraEnd = null) { const pts = cells.map(p => cellCenter(p.col, p.row)); @@ -113,8 +157,27 @@ export function chooseRoundRobin(game, id, candidates, advance = false) { } export function scannerOutputs(scanner) { - if (scanner.role === 0) return { left: 'mixer', right: 'scanner-role-1' }; - return { left: 'trash', right: 'truck' }; + const rules = scannerRules(scanner); + return { left: rules.left.destination, right: rules.right.destination }; +} +export function scannerRules(scanner) { + if (scanner.role === 0) { + return { + left: { match: 'male', destination: 'mixer' }, + right: { match: 'default', destination: 'scanner-role-1' } + }; + } + return { + left: { match: 'poop', destination: 'trash' }, + right: { match: 'default', destination: 'truck' } + }; +} +export function autoSideFor(scanner, chick) { + const rules = scannerRules(scanner); + if (rules.left.match === chick.sex) return 'left'; + if (rules.right.match === chick.sex) return 'right'; + if (rules.left.match === 'default') return 'left'; + return 'right'; } export function destinationLabel(dest) { return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase(); @@ -171,7 +234,7 @@ export function isFacilityEndpoint(game, p, dest, connector) { } export function routeFromFarmToScanner(game, farm, advance = false) { - const starts = getAdjacentConveyors(game, farm.col, farm.row).filter(p => !isOutputConnector(game, p.col, p.row)); + const starts = getAdjacentConveyors(game, farm.col, farm.row); const candidates = []; for (const start of starts) { const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row)); @@ -183,33 +246,48 @@ export function routeFromFarmToScanner(game, farm, advance = false) { } } 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)) }; + if (chosen) return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) }; + const fallback = chooseRoundRobin(game, `farm:${farm.id}:output`, starts.map(p => ({ key: key(p.col, p.row), cells: [p] })), advance); + return fallback ? { scannerId: null, route: routePoints(fallback.cells) } : null; } 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 starts = outputStartCells(game, scanner, side); + if (!connector || !starts.length) 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 }); + for (const start of starts) { + for (const target of targets) { + const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row))); + const routes = bfsAllRoutes(game, start, p => inputKeys.has(key(p.col, p.row))); + for (const cells of routes) { + candidates.push({ + key: `start${key(start.col, start.row)}: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))] }; + return { destination: 'scanner', nextScannerId: chosen.target.id, route: routeWithConnector(fromPoint, connector, chosen.cells, scannerCenter(chosen.target)) }; + } + const candidates = []; + for (const start of starts) { + const routes = bfsAllRoutes(game, start, p => isFacilityEndpoint(game, p, dest, connector)); + for (const cells of routes) { + candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells }); + } } - 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))] }; + return { destination: dest, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, dest)) }; } export function facilityConnectionIssues(game) { diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js new file mode 100644 index 0000000..7889502 --- /dev/null +++ b/src/systems/selectionSystem.js @@ -0,0 +1,247 @@ +import { BUILD_COSTS, GRID } from '../core/config.js'; +import { key, parseKey, cellCenter } from '../core/utils.js'; +import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js'; +import { farmAt, scannerAt, scannerCenter } from './routing.js'; +import { snapshot } from './history.js'; + +export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) { + 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(); + } + + return { + selectionFromHit, + startSelectionBox, + updateSelectionBox, + finishSelectionBox, + startGroupDrag, + updateGroupDrag, + finishGroupDrag + }; +} diff --git a/src/systems/stats.js b/src/systems/stats.js new file mode 100644 index 0000000..db72d32 --- /dev/null +++ b/src/systems/stats.js @@ -0,0 +1,45 @@ +export function countProcessed(game) { + game.totals.processed += 1; +} + +export function countCorrect(game) { + game.stats.correct += 1; + game.totals.correct += 1; +} + +export function countMistake(game) { + game.stats.mistakes += 1; + game.totals.mistakes += 1; +} + +export function countAutoSorted(game) { + game.stats.autoSorted += 1; + game.totals.autoSorted += 1; +} + +export function countPoopSpawned(game) { + game.stats.poopSpawned += 1; +} + +export function countMixer(game) { + game.stats.mixerCount += 1; + countProcessed(game); +} + +export function countTrash(game) { + game.stats.trashCount += 1; + countProcessed(game); +} + +export function countTruckCargo(game, sex) { + if (sex === 'female') game.stats.truckFemale += 1; + else if (sex === 'male') game.stats.truckMale += 1; + countProcessed(game); +} + +export function countPoopDestination(game, destination) { + const key = destination === 'truck' ? 'poopTruck' : destination === 'trash' ? 'poopTrash' : destination === 'mixer' ? 'poopMixer' : null; + if (!key) return; + game.stats[key] += 1; + game.totals[key] += 1; +} diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index efe1758..f13904d 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -1,12 +1,11 @@ -import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, CONTRACT_EVENT_FIRST_TURN, STARTING_CASH, VERSION } from '../core/config.js'; +import { CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FAIRIES_FEE_PER_DAY, 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'; +import { finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice } from './economy.js'; +import { truckTarget } 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 displaySpeed() { return Math.round(Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED)); } function updatePanels() { updateToolButtons(); @@ -25,8 +24,8 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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.'; + ? `BUILD: ${(game.buildTool || 'SELECT').toUpperCase()} | Right-drag pan | Click equipment for upgrade menu` + : 'Sorting: use scanner keys. Build after clearing the day.'; } function updateHistoryButtons() { @@ -37,9 +36,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } function updateTurnSummary() { const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length; const issues = facilityConnectionIssues(game); + const feeDay = game.phase === 'build' ? game.turn + 1 : game.turn; + const fairiesFee = Math.ceil(Math.max(1, feeDay) * FAIRIES_FEE_PER_DAY); ui.turnSummary.innerHTML = [ `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, - `Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`, + `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, + `Fairies社 fee: Day ${feeDay} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(fairiesFee)}`, + `Belt: ${displaySpeed()}px/s fixed | Farms connected: ${connected}/${game.eggFarms.length}`, issues.length ? `Blocked: ${issues[0]}` : `All receiver ports connected.` ].join('
'); } @@ -55,7 +58,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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}.`; + ui.contractPanel.innerHTML = `Event lock active.No irregular events during Days 1-7. First possible forced event: Day ${CONTRACT_EVENT_FIRST_TURN}.`; } else { ui.contractPanel.innerHTML = 'No event.Random forced events can appear later.'; } @@ -63,13 +66,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } } ui.contractPanel.className = 'contract-card accepted'; ui.contractPanel.innerHTML = ` -
FORCED NEXT TURN
+
FORCED NEXT DAY

${offer.title}

Route: ${offer.route} Target: ${offer.targetAmount} truck exports Reward: ${yen(offer.reward)} - Output ×${offer.productionMultiplier} / Poop ×${offer.poopMultiplier} + Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier}
`; } @@ -77,25 +80,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } const obj = build.selectedObject(); if (!obj) { ui.facilityPanel.className = 'facility-panel-empty'; - ui.facilityPanel.innerHTML = 'Click equipment to upgrade, remove, or move it.'; + ui.facilityPanel.innerHTML = 'Click equipment to open upgrade / remove choices.'; 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); + ui.facilityPanel.innerHTML = `

${build.selectedTitle(obj)}

${lines.map(x => `

${x}

`).join('')}

Upgrade choices appear in the clicked equipment menu.

`; } function updateUI() { @@ -107,8 +98,10 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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.timeLeft.textContent = game.phase === 'running' && game.timeLeft <= 0 && game.shutdownTimeLeft > 0 + ? `+${game.shutdownTimeLeft.toFixed(1)}s` + : `${Math.max(0, game.timeLeft).toFixed(1)}s`; + ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0))); ui.phase.textContent = phaseLabel(); updatePriorityStrip(); ui.turnProfit.textContent = yen(game.stats.profit); @@ -130,17 +123,17 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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.'; + ui.speedBrief.title = `Conveyor speed is fixed by day. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; 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.textContent = `ACTIVE EVENT: ${active.route} | Output x${active.productionMultiplier} | Poop x${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.textContent = `NEXT DAY 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.textContent = `NO EVENTS UNTIL DAY ${CONTRACT_EVENT_FIRST_TURN}`; ui.eventBrief.className = 'event-brief locked'; } else { ui.eventBrief.textContent = 'NO ACTIVE EVENT'; @@ -157,14 +150,15 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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.modalBody.innerHTML = `
MMale->MixerS1: A
FFemale->TruckS1: D, then S2: right
PPoop->ShredderS1: D, then S2: left

Build phase: connect all receiver ports before Next Day. One day is one run.

`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Start Game', startGame, 'primary-button')); + ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } function showTurnResult(r) { - ui.modalTitle.textContent = `Turn ${r.turn} Result`; + ui.modalTitle.textContent = `Day ${r.turn} Result`; const contractHtml = r.contract ? `
Limited Contract
@@ -172,23 +166,28 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
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.
'; + ` : '
No limited contract active this day.
'; + const contractBonus = r.contract?.bonus || 0; + const dailyEarned = r.revenue; ui.modalBody.innerHTML = ` -
Shipment
+
Shipment / Daily Revenue
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)}%
+
Truck formula${r.ship.targetCount} × ${yen(r.ship.unitPrice)} = ${yen(r.ship.base)}
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)}
+
Mixer income${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}
+
Poop fineMixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}
+
Wrong truck fine${yen(r.maleTruckFine)}
+
Fairies社 feeDay ${r.turn} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(r.fairiesFee)}
+
Explosion penalty${yen(r.explosionDamage)}
+
Daily earned formula(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}
+
Net profit formula${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}
Cash${yen(r.cash)}
${contractHtml}`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Build Phase', hideModal, 'primary-button')); + ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } @@ -197,9 +196,12 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } 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.modalBody.innerHTML = `

Your cash went negative.

+
Final score formula
Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}
Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}
+
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
Fairies社 Fees${yen(game.totals.fairiesFee)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Restart', startGame, 'primary-button')); + ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } @@ -212,7 +214,11 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } return b; } - function hideModal() { ui.modal.classList.remove('visible'); } + function hideModal() { + ui.modal.classList.remove('visible', 'equipment-popover'); + ui.modal.style.removeProperty('--popover-x'); + ui.modal.style.removeProperty('--popover-y'); + } function checkGameOver() { if (game.phase !== 'gameover' && game.cash < 0) { diff --git a/styles.css b/styles.css index 1a36dbe..2025574 100644 --- a/styles.css +++ b/styles.css @@ -117,3 +117,66 @@ h1, h2, p { margin: 0; } .compact-rules .mini-box { padding: 8px; font-size: 10px; } .primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; } +.formula-box { + border: 3px solid var(--line); + background: #f7fff5; + padding: 10px; + margin: 10px 0; + line-height: 1.5; + color: var(--ink); + font-size: 12px; + font-weight: 700; +} +.equipment-menu-lines { + border: 3px solid var(--line); + background: #ffffff; + padding: 10px; + margin: 8px 0; + color: var(--muted); + line-height: 1.45; +} +.equipment-menu-lines p { margin: 4px 0; } + +/* v11 compact clicked-equipment bubble */ +.modal.equipment-popover { + background: transparent; + align-items: flex-start; + justify-content: flex-start; + padding: 0; + pointer-events: none; +} +.modal.equipment-popover .modal-card { + position: absolute; + left: var(--popover-x, 16px); + top: var(--popover-y, 16px); + width: min(320px, calc(100vw - 24px)); + max-height: min(360px, calc(100vh - 24px)); + overflow: auto; + padding: 10px; + border-width: 3px; + pointer-events: auto; + box-shadow: 6px 6px 0 rgba(16,32,21,.18); +} +.modal.equipment-popover .modal-card h2 { + font-size: 14px; + margin-bottom: 6px; +} +.modal.equipment-popover .modal-actions { + margin-top: 8px; + gap: 6px; + justify-content: flex-start; +} +.modal.equipment-popover .modal-actions button { + padding: 7px 9px; + font-size: 10px; +} +.equipment-menu-lines.compact { + padding: 7px; + margin: 6px 0; + font-size: 10px; +} +.formula-box.compact { + padding: 7px; + margin: 6px 0; + font-size: 10px; +}