This commit is contained in:
33333-33333 2026-06-07 18:06:35 +09:00
commit b454ffbd40
21 changed files with 1349 additions and 712 deletions

View file

@ -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

View file

@ -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`

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v10.7 UI / Ports / Shutter</title>
<title>Chick Sorter v11.0 Routing / Compact Upgrades</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -12,21 +12,21 @@
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
<div class="hud-card cash"><span>CASH</span><strong id="money">¥200</strong></div>
<div class="hud-card cash"><span>CASH</span><strong id="money">JPY 250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>PROFIT</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>TURN</span><strong id="turn">1</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">JPY 0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
</div>
<div class="hud hud-top-right" aria-label="Turn result details">
<div class="hud hud-top-right" aria-label="Day result details">
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
</div>
<div class="priority-strip" aria-label="Important turn info">
<div class="priority-strip" aria-label="Important day info">
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
@ -35,32 +35,31 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v10.7</span></h1>
<p>Build, drag, connect all receiver ports, then start the next turn.</p>
<h1>CHICK SORTER <span>v11.0</span></h1>
<p>Build, drag, connect all receiver ports, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Turn</button>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
<div class="panel-grid">
<section class="equipment-section">
<h2>Add / Edit Equipment</h2>
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥220</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 4.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>JPY 30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>JPY 300</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>JPY 360 / 4.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>JPY 260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>JPY 450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>JPY 240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>JPY 450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</span></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each turn.</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section>
<section class="contract-section">
<h2>Irregular One-Turn Event</h2>
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
@ -77,10 +76,10 @@
</div>
<div class="control-dock" aria-label="Sorting controls">
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A Mixer</button>
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D S2</button>
<button id="scanner2MixerButton" class="sort-button" type="button">S2 Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 Truck</button>
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
</section>
</main>
@ -93,6 +92,6 @@
</div>
</div>
<script type="module" src="./src/game.js?v=10.7-ui-ports-shutter"></script>
<script type="module" src="./src/game.js?v=11.0-routing-compact-upgrades"></script>
</body>
</html>

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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)); }

View file

@ -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);

View file

@ -3,67 +3,83 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v6.0 Visibility Pass</title>
<title>Chick Sorter v11.0 Routing / Compact Upgrades</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app">
<section id="gameShell" class="game-shell" aria-label="Game screen">
<canvas id="gameCanvas" width="1440" height="810" aria-label="Chick Sorter game canvas"></canvas>
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
<div class="hud-card cash"><span>CASH</span><strong id="money">¥140</strong></div>
<div class="hud-card cash"><span>CASH</span><strong id="money">JPY 250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>PROFIT</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>TURN</span><strong id="turn">1</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">JPY 0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
</div>
<div class="hud hud-top-right" aria-label="Turn result details">
<div class="hud hud-top-right" aria-label="Day result details">
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
<div class="hud-card"><span>RANDOM</span><strong id="missedCount">0</strong></div>
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
</div>
<div id="upgradePanel" class="upgrade-panel" aria-label="Upgrades and build tools">
<div class="priority-strip" aria-label="Important day info">
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
</div>
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v6.0</span></h1>
<p>Industrial scanner junctions. Conveyor tiles can bend; scanners split to left/right output belts.</p>
<h1>CHICK SORTER <span>v11.0</span></h1>
<p>Build, drag, connect all receiver ports, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Turn</button>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
<div class="panel-grid">
<section>
<h2>Build</h2>
<div class="tool-list">
<button id="buildConveyorButton" class="tool-button" type="button">Conveyor Tile</button>
<button id="buildEggFarmButton" class="tool-button" type="button">Egg Farm</button>
<button id="moveScannerButton" class="tool-button" type="button">Scanner Table</button>
<button id="eraseButton" class="tool-button danger" type="button">Erase Belt</button>
<section class="equipment-section">
<h2>Add / Edit Equipment</h2>
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>JPY 30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>JPY 300</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>JPY 360 / 4.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>JPY 260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>JPY 450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>JPY 240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>JPY 450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</span></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each 60-second turn.</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section>
<section>
<h2>Upgrades</h2>
<div id="upgradeList" class="upgrade-list"></div>
<section class="contract-section">
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
<section>
<h2>Live Rules</h2>
<section class="facility-section">
<h2>Selected Facility</h2>
<div id="facilityPanel" class="facility-panel-empty">Click equipment in Build phase.</div>
</section>
<section class="rules-section compact-rules">
<h2>Status</h2>
<div id="turnSummary" class="mini-box"></div>
</section>
</div>
</div>
<div class="control-dock" aria-label="Sorting controls">
<button id="scanner1MixerButton" class="sort-button mixer" type="button">S1 A Mixer</button>
<button id="scanner1TruckButton" class="sort-button truck" type="button">S1 D Truck</button>
<button id="scanner2MixerButton" class="sort-button mixer" type="button">S2 ← Mixer</button>
<button id="scanner2TruckButton" class="sort-button truck" type="button">S2 Truck</button>
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
</section>
</main>
@ -76,6 +92,6 @@
</div>
</div>
<script src="./src/game.js"></script>
<script type="module" src="./game.js?v=11.0-routing-compact-upgrades"></script>
</body>
</html>

View file

@ -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) {

View file

@ -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;
}

View file

@ -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 = `
<div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div>
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income = ceil(base × 1.05^upgrade count). Fines use the same multiplier.</p>' : ''}`;
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
};

436
src/systems/chickSystem.js Normal file
View file

@ -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
};
}

View file

@ -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.

View file

@ -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 };

View file

@ -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;

View file

@ -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) {

View file

@ -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
};
}

45
src/systems/stats.js Normal file
View file

@ -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;
}

View file

@ -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: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Belt: <b>${displaySpeed()}px/s</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`Fairies社 fee: <b>Day ${feeDay} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(fairiesFee)}</b>`,
`Belt: <b>${displaySpeed()}px/s fixed</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">All receiver ports connected.</span>`
].join('<br>');
}
@ -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 = `<strong>Event lock active.</strong><span>No irregular events during Turns 17. First possible forced event: Turn ${CONTRACT_EVENT_FIRST_TURN}.</span>`;
ui.contractPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Days 1-7. First possible forced event: Day ${CONTRACT_EVENT_FIRST_TURN}.</span>`;
} else {
ui.contractPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
}
@ -63,13 +66,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
}
ui.contractPanel.className = 'contract-card accepted';
ui.contractPanel.innerHTML = `
<div class="contract-status">FORCED NEXT TURN</div>
<div class="contract-status">FORCED NEXT DAY</div>
<h3>${offer.title}</h3>
<div class="contract-metrics">
<span>Route: ${offer.route}</span>
<span>Target: ${offer.targetAmount} truck exports</span>
<span>Reward: ${yen(offer.reward)}</span>
<span>Output ×${offer.productionMultiplier} / Poop ×${offer.poopMultiplier}</span>
<span>Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier}</span>
</div>`;
}
@ -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(`<button class="facility-action good" data-action="upgrade">Upgrade ${yen(cost)}</button>`);
if (obj.type === 'scanner') actions.push(`<button class="facility-action warn" data-action="role">${obj.kind === 'auto' ? 'Change Auto Menu' : 'Switch Role'}</button>`);
const refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession;
actions.push(`<button class="facility-action danger" data-action="remove">Remove${refundable ? ' / Refund' : ''}</button>`);
ui.facilityPanel.innerHTML = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<div class="facility-actions">${actions.join('')}</div>`;
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 = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<p><b>Upgrade choices appear in the clicked equipment menu.</b></p>`;
}
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 = `<div class="route-cards"><div class="route-card"><span class="route-icon">🐤</span><strong>Male</strong><b>→</b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon">🐣</span><strong>Female</strong><b>→</b><em>Truck</em><small>S1: D, then S2: →</small></div><div class="route-card"><span class="route-icon">💩</span><strong>Poop</strong><b>→</b><em>Shredder</em><small>S1: D, then S2: ←</small></div></div><p>Build phase: connect all receiver ports before Next Turn.</p>`;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon">M</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon">F</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon">P</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect all receiver ports before Next Day. One day is one run.</p>`;
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 ? `
<div class="result-section-title">Limited Contract</div>
<div class="result-grid">
@ -172,23 +166,28 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
<div><strong>Rule</strong><span>${r.contract.route}</span></div>
<div><strong>Progress</strong><span>${r.contract.count} / ${r.contract.targetAmount}</span></div>
<div><strong>Bonus</strong><span>${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}</span></div>
</div>` : '<div class="result-section-title muted-title">No limited contract active this turn.</div>';
</div>` : '<div class="result-section-title muted-title">No limited contract active this day.</div>';
const contractBonus = r.contract?.bonus || 0;
const dailyEarned = r.revenue;
ui.modalBody.innerHTML = `
<div class="result-section-title">Shipment</div>
<div class="result-section-title">Shipment / Daily Revenue</div>
<div class="result-grid">
<div><strong>Truck target</strong><span>${r.ship.target.toUpperCase()}</span></div>
<div><strong>Target cargo</strong><span>${r.ship.targetCount} × ${yen(TRUCK_PRICE)} = ${yen(r.ship.base)}</span></div>
<div><strong>Poop in truck</strong><span>${r.poopTruck} poop ${Math.round(r.ship.soilMultiplier * 100)}%</span></div>
<div><strong>Truck formula</strong><span>${r.ship.targetCount} × ${yen(r.ship.unitPrice)} = ${yen(r.ship.base)}</span></div>
<div><strong>Final truck revenue</strong><span>${yen(r.ship.adjusted)}</span></div>
<div><strong>Mixer</strong><span>${r.mixerCount} × ${yen(MIXER_PRICE)}</span></div>
<div><strong>Wrong truck</strong><span>${r.truckMale} males</span></div>
<div><strong>Explosion damage</strong><span>${yen(r.explosionDamage)}</span></div>
<div><strong>Turn profit</strong><span>${yen(r.profit)}</span></div>
<div><strong>Mixer income</strong><span>${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}</span></div>
<div><strong>Poop fine</strong><span>Mixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}</span></div>
<div><strong>Wrong truck fine</strong><span>${yen(r.maleTruckFine)}</span></div>
<div><strong>Fairies社 fee</strong><span>Day ${r.turn} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(r.fairiesFee)}</span></div>
<div><strong>Explosion penalty</strong><span>${yen(r.explosionDamage)}</span></div>
<div><strong>Daily earned formula</strong><span>(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}</span></div>
<div><strong>Net profit formula</strong><span>${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</span></div>
<div><strong>Cash</strong><span>${yen(r.cash)}</span></div>
</div>
${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 = `<p>Your cash went negative.</p><div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} turns</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Total Penalty</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} truck / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Damage</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
ui.modalBody.innerHTML = `<p>Your cash went negative.</p>
<div class="formula-box"><strong>Final score formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>Fairies Fees</strong><span>${yen(game.totals.fairiesFee)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
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) {

View file

@ -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;
}