Compare commits
2 commits
a322158f43
...
6d6fbe85a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d6fbe85a9 | |||
| b454ffbd40 |
23 changed files with 1897 additions and 1026 deletions
51
README.md
51
README.md
|
|
@ -1,30 +1,25 @@
|
|||
# Chick Sorter v10.7 UI / Ports / Shutter
|
||||
# Chick Sorter v14.0 Zunda Tax / Resale
|
||||
|
||||
Open `index.html` in a browser.
|
||||
## Changes in this build
|
||||
- Changed Egg Farm build price to `¥250`.
|
||||
- Replaced the previous daily Fairies fee with `ZUNDA TAX`, paid when `Next Day` is pressed.
|
||||
- `ZUNDA TAX` taxes only cash above `¥500`: `ceil((cash - 500) × rate)`, with 5% added per `¥500` band and a 95% cap.
|
||||
- Removing equipment from a previous Build phase now sells it for 50% of its build price; same-Build removals still refund 100%.
|
||||
- Removed visible `STOPPED` text/highlight. Blocked chicks simply stay still.
|
||||
- Removed route-overlay arrows/colored flow arrows. Conveyor movement is now shown by small direction triangles inside belt tiles.
|
||||
- Moved the starter S1 position lower so the Egg Farm and S1 have more distance.
|
||||
- Moved the Truck to the bottom side of the grid and connected the starter S2-to-Truck belt to the new receiver.
|
||||
- Made scanner and facility ports strict: a belt must occupy the exact port cell to count as connected.
|
||||
- Expanded `Next Day` validation to scanner inputs/outputs and planned output routes, not just machine receiver cells.
|
||||
- Build buttons for unaffordable equipment, or already-built unique machines, are disabled and visually faded.
|
||||
- Removed Close buttons from clicked-equipment/upgrade popovers. Click outside the popover to close it.
|
||||
- Kept `src/index.html` and `src/styles.css` removed; the project uses the root `index.html` and `styles.css` only.
|
||||
- Continued centralized price/income/fine/facility definitions in `src/core/config.js`, shared text in `src/core/text.js`, and economy logic in `src/systems/economy.js`.
|
||||
|
||||
## v10.7 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.
|
||||
|
||||
## Controls
|
||||
|
||||
Sorting:
|
||||
- S1: A = Mixer, D = S2
|
||||
- S2: Left Arrow = Shredder, Right Arrow = Truck
|
||||
|
||||
Build phase:
|
||||
- Left drag empty area: box select
|
||||
- Drag selected equipment: move selection
|
||||
- Right drag: pan
|
||||
- Ctrl+Z / Ctrl+Y: undo / redo
|
||||
## Structure ideas for the next cleanup pass
|
||||
- Move HTML button content to a data-rendered build panel instead of hard-coded markup.
|
||||
- Give every equipment object a `defId`, so runtime state never duplicates names, prices, sizes, or upgrade rules.
|
||||
- Store routes as cell arrays and convert to pixel paths only at movement/draw time.
|
||||
- Cache route plans per build session, then invalidate once on build/erase/drag instead of recomputing every draw.
|
||||
- Split route validation into errors and warnings, so optional/unused equipment can be supported later without blocking the day.
|
||||
- Remove legacy fallback paths after save compatibility is no longer needed.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
47
index.html
47
index.html
|
|
@ -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 v14.0 Zunda Tax / Resale</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">¥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">¥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>v14.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>¥30 / tile</span></button>
|
||||
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</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="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=14.0-zunda-tax-resale"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,36 +1,78 @@
|
|||
export const VERSION = 'v10.7 UI / Ports / Shutter';
|
||||
export const VERSION = 'v14.0 Zunda Tax / Resale';
|
||||
|
||||
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;
|
||||
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 ECONOMY = {
|
||||
income: {
|
||||
mixer: 5,
|
||||
truck: 10
|
||||
},
|
||||
poopFine: 10,
|
||||
zundaTax: {
|
||||
exemption: 500,
|
||||
stepAmount: 500,
|
||||
stepRate: 0.05,
|
||||
maxRate: 0.95
|
||||
},
|
||||
incomeUpgradeRate: 1.05,
|
||||
explosionDamageDivisor: 30,
|
||||
maleTruckFinePerHalfDay: 30
|
||||
};
|
||||
|
||||
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;
|
||||
export const POOP_RATE = 0.08;
|
||||
export const CONVEYOR_SPEED = 52;
|
||||
export const CONVEYOR_SPEED_MAX = 300;
|
||||
|
||||
export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 };
|
||||
|
||||
export const BUILD_COSTS = {
|
||||
conveyor: 30,
|
||||
eggFarm: 220,
|
||||
manualScanner: 260,
|
||||
autoScanner: 360,
|
||||
mixer: 450,
|
||||
trash: 240,
|
||||
truck: 450
|
||||
export const FACILITY_DEFS = {
|
||||
conveyor: {
|
||||
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
eggFarm: {
|
||||
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
||||
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
|
||||
},
|
||||
manualScanner: {
|
||||
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
autoScanner: {
|
||||
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
|
||||
buildable: true, upgradeable: false
|
||||
},
|
||||
mixer: {
|
||||
id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 }
|
||||
},
|
||||
trash: {
|
||||
id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240,
|
||||
buildable: true, upgradeable: false, body: { w: 180, h: 100 }
|
||||
},
|
||||
truck: {
|
||||
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
|
||||
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
|
||||
}
|
||||
};
|
||||
|
||||
export const FACILITY_PRICES = {
|
||||
mixer: 450,
|
||||
trash: 240,
|
||||
truck: 450
|
||||
};
|
||||
export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
|
||||
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
|
||||
|
||||
// Compatibility exports. New code should prefer FACILITY_DEFS and ECONOMY.
|
||||
export const BUILD_COSTS = Object.fromEntries(Object.entries(FACILITY_DEFS).map(([id, def]) => [id, def.price]));
|
||||
export const FACILITY_PRICES = Object.fromEntries(MACHINE_FACILITY_IDS.map(id => [id, FACILITY_DEFS[id].price]));
|
||||
export const MIXER_PRICE = ECONOMY.income.mixer;
|
||||
export const TRUCK_PRICE = ECONOMY.income.truck;
|
||||
export const POOP_FINE = ECONOMY.poopFine;
|
||||
export const ZUNDA_TAX = ECONOMY.zundaTax;
|
||||
export const INCOME_UPGRADE_RATE = ECONOMY.incomeUpgradeRate;
|
||||
|
||||
export const DIRS = [
|
||||
{ name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js';
|
||||
import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
|
||||
import { nextSpawnDelay } from './state.js';
|
||||
import { currentPoopRate } from '../systems/contracts.js';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ export function createEggFarm(game, col, row) {
|
|||
}
|
||||
|
||||
export function createScanner(game, col, row, kind) {
|
||||
const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
|
||||
const cost = kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price;
|
||||
const manualCount = game.scanners.filter(s => s.kind === 'manual').length;
|
||||
return {
|
||||
type: 'scanner', id: game.nextId++, kind,
|
||||
|
|
@ -59,11 +59,10 @@ export function layoutFacilityOnEdge(facility, entry, side) {
|
|||
|
||||
export function createFacility(game, id, p, price) {
|
||||
const { entry, side } = nearestGridEdge(p);
|
||||
const spec = {
|
||||
mixer: { name: 'Mixer', w: 164, h: 126, price: FACILITY_PRICES.mixer },
|
||||
trash: { name: 'Waste Shredder', w: 180, h: 100, price: FACILITY_PRICES.trash },
|
||||
truck: { name: 'Truck', w: 176, h: 138, price: FACILITY_PRICES.truck }
|
||||
}[id] || { name: id[0].toUpperCase() + id.slice(1), w: 168, h: 110, price };
|
||||
const def = FACILITY_DEFS[id];
|
||||
const spec = def
|
||||
? { name: def.name, w: def.body?.w || 168, h: def.body?.h || 110, price: def.price }
|
||||
: { name: id[0].toUpperCase() + id.slice(1), w: 168, h: 110, price };
|
||||
const f = {
|
||||
type: 'facility', id,
|
||||
name: spec.name,
|
||||
|
|
|
|||
|
|
@ -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_DEFS, 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,
|
||||
zundaTax: 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,
|
||||
zundaTax: 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,
|
||||
|
|
@ -97,14 +109,10 @@ export function nextSpawnDelay(farm) {
|
|||
}
|
||||
|
||||
function facilityBodyForEntry(id, entry, side) {
|
||||
const sizes = {
|
||||
mixer: { w: 164, h: 126, name: 'Mixer', price: FACILITY_PRICES.mixer },
|
||||
trash: { w: 180, h: 100, name: 'Waste Shredder', price: FACILITY_PRICES.trash },
|
||||
truck: { w: 176, h: 138, name: 'Truck', price: FACILITY_PRICES.truck }
|
||||
};
|
||||
const def = FACILITY_DEFS[id];
|
||||
const spec = { ...def.body, name: def.name, price: def.price };
|
||||
const g = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
|
||||
const c = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
|
||||
const spec = sizes[id];
|
||||
let x = c.x - spec.w / 2;
|
||||
let y = c.y - spec.h / 2;
|
||||
if (side === 'left') { x = g.left - spec.w - 18; y = c.y - spec.h / 2; }
|
||||
|
|
@ -117,9 +125,9 @@ 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'),
|
||||
truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right')
|
||||
mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'),
|
||||
trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'),
|
||||
truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom')
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -127,26 +135,27 @@ 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. S1 starts lower so the farm is not pressed against it.
|
||||
[4, 1], [4, 2], [4, 3], [4, 4],
|
||||
// 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, 5], [2, 5], [1, 5], [0, 5],
|
||||
// S1 right output -> S2 top input.
|
||||
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
|
||||
[11, 6], [11, 7],
|
||||
// S2 left output -> Waste Shredder receiver on bottom grid edge.
|
||||
[16, 7], [16, 8], [16, 9], [16, 10], [16, 11], [16, 12],
|
||||
// S2 right output -> Truck receiver on the right grid edge.
|
||||
[18, 7], [19, 7], [20, 7], [21, 7]
|
||||
]; for (const [col, row] of initial) {
|
||||
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
|
||||
// S2 right output -> Truck receiver on the bottom grid edge.
|
||||
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
|
||||
];
|
||||
for (const [col, row] of initial) {
|
||||
const k = `${col},${row}`;
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null });
|
||||
}
|
||||
game.facilities = defaultFacilities();
|
||||
game.scanners = [
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 6, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
|
||||
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 17, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, 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: 8, 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);
|
||||
|
|
|
|||
78
src/core/text.js
Normal file
78
src/core/text.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_TOOL_IDS, FACILITY_DEFS, VERSION } from './config.js';
|
||||
import { yen } from './utils.js';
|
||||
|
||||
export const TEXT = {
|
||||
appTitle: 'Chick Sorter',
|
||||
versionTitle: `Chick Sorter ${VERSION}`,
|
||||
currency: '¥',
|
||||
phases: {
|
||||
title: 'Title',
|
||||
build: 'Build',
|
||||
running: 'Sorting',
|
||||
clearing: 'Clearing',
|
||||
gameover: 'Game Over'
|
||||
},
|
||||
actions: {
|
||||
startGame: 'Start Game',
|
||||
nextDay: 'Next Day',
|
||||
buildPhase: 'Build Phase',
|
||||
restart: 'Restart',
|
||||
close: 'Close',
|
||||
remove: 'Remove',
|
||||
refund: 'Refund',
|
||||
autoMenu: 'Auto Menu'
|
||||
},
|
||||
status: {
|
||||
build: tool => `BUILD: ${(tool || 'SELECT').toUpperCase()} | Right-drag pan | Click equipment for menu`,
|
||||
sorting: 'Sorting: use scanner keys. Build after clearing the day.',
|
||||
allPortsConnected: 'All required ports connected.',
|
||||
equipmentPanelEmpty: 'Click equipment in Build phase.',
|
||||
eventPanelRunning: 'Forced events appear in Build phase.'
|
||||
},
|
||||
routeLabels: {
|
||||
mixer: 'MIXER',
|
||||
truck: 'TRUCK',
|
||||
trash: 'WASTE',
|
||||
scanner: 'NEXT',
|
||||
'scanner-role-1': 'NEXT',
|
||||
input: 'IN'
|
||||
},
|
||||
fail: {
|
||||
outOfGrid: 'Out of grid',
|
||||
cellOccupied: 'Cell occupied',
|
||||
notEnoughCash: 'Not enough cash',
|
||||
facilityExists: 'Facility already exists',
|
||||
facilityOverlap: 'Facility overlap',
|
||||
nothingToErase: 'Nothing to erase',
|
||||
noUpgrade: 'No upgrade available'
|
||||
}
|
||||
};
|
||||
|
||||
export function equipmentName(id) {
|
||||
return FACILITY_DEFS[id]?.name || String(id || '').replace(/^[a-z]/, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function shortEquipmentName(id) {
|
||||
return FACILITY_DEFS[id]?.shortName || equipmentName(id);
|
||||
}
|
||||
|
||||
export function buildToolPriceText(id) {
|
||||
const def = FACILITY_DEFS[id];
|
||||
if (!def) return '';
|
||||
if (id === 'conveyor') return `${yen(def.price)} / tile`;
|
||||
if (id === 'autoScanner') return `${yen(def.price)} / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s cooldown`;
|
||||
if (id === 'trash') return yen(def.price);
|
||||
return yen(def.price);
|
||||
}
|
||||
|
||||
export function buildToolButtonHtml(id) {
|
||||
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>`;
|
||||
}
|
||||
|
||||
export function buildToolButtonIds() {
|
||||
return BUILD_TOOL_IDS;
|
||||
}
|
||||
|
||||
export function routeLabel(dest) {
|
||||
return TEXT.routeLabels[dest] || String(dest).toUpperCase();
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { GRID } from './config.js';
|
||||
|
||||
export function yen(value) { return `¥${Math.floor(value).toLocaleString('en-US')}`; }
|
||||
export function yen(value) {
|
||||
const n = Number(value) || 0;
|
||||
const sign = n < 0 ? '-' : '';
|
||||
return `${sign}¥${Math.ceil(Math.abs(n)).toLocaleString('ja-JP')}`;
|
||||
}
|
||||
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)); }
|
||||
|
|
|
|||
343
src/game.js
343
src/game.js
|
|
@ -1,15 +1,16 @@
|
|||
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 { buildToolButtonHtml } from './core/text.js';
|
||||
import { createGame, resetLayout, newTurnStats } from './core/state.js';
|
||||
import { clamp, pointToCell, cellCenter, yen } from './core/utils.js';
|
||||
import { facilityConnectionIssues } from './systems/routing.js';
|
||||
import { collectZundaTax, 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');
|
||||
|
|
@ -27,21 +28,36 @@ const ui = {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
function initializeStaticText() {
|
||||
const buttonToolMap = {
|
||||
conveyor: 'conveyor',
|
||||
eggFarm: 'eggFarm',
|
||||
autoScanner: 'autoScanner',
|
||||
manualScanner: 'manualScanner',
|
||||
mixer: 'mixer',
|
||||
trash: 'trash',
|
||||
truck: 'truck'
|
||||
};
|
||||
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
|
||||
if (ui.buttons[buttonKey]) ui.buttons[buttonKey].innerHTML = buildToolButtonHtml(toolId);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -54,21 +70,29 @@ function startNextTurn() {
|
|||
uiSystem.updatePanels();
|
||||
return;
|
||||
}
|
||||
const zundaBasisCash = game.cash;
|
||||
activateAcceptedContract(game);
|
||||
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();
|
||||
const zunda = collectZundaTax(game, zundaBasisCash);
|
||||
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
|
||||
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();
|
||||
}
|
||||
|
|
@ -76,18 +100,21 @@ function startNextTurn() {
|
|||
function completeTurn() {
|
||||
if (game.phase !== 'running') return;
|
||||
const ship = settleTruckRevenue(game);
|
||||
const contract = resolveContract(game, applyCashDelta);
|
||||
const contract = resolveContract(game);
|
||||
clearActiveContract(game);
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
game.totals.turnsCompleted += 1;
|
||||
game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash };
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
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 +125,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 +164,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 +175,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);
|
||||
});
|
||||
|
|
@ -413,12 +202,18 @@ canvas.addEventListener('pointerup', event => {
|
|||
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; });
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
|
||||
if (event.target.closest('#modal .modal-card')) return;
|
||||
uiSystem.hideModal();
|
||||
}, true);
|
||||
|
||||
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 });
|
||||
initializeStaticText(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
|
||||
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js';
|
||||
import { upgradedMixerPrice, 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',
|
||||
|
|
@ -126,89 +126,68 @@ function drawConveyors(ctx, game) {
|
|||
ctx.lineWidth = 6;
|
||||
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
|
||||
}
|
||||
const directionMarkers = collectConveyorDirectionMarkers(game);
|
||||
for (const k of game.conveyorTiles) {
|
||||
const c = cellCenter(...Object.values(parseKey(k)));
|
||||
const ratio = componentRatio(game, k);
|
||||
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
|
||||
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
|
||||
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
|
||||
}
|
||||
for (const farm of game.eggFarms) {
|
||||
const data = routeFromFarmToScanner(game, farm);
|
||||
if (data) drawRouteFlow(ctx, data.route, THEME.muted, 3);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function cellKeyFromPoint(game, p) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (!cell) return null;
|
||||
const center = cellCenter(cell.col, cell.row);
|
||||
if (Math.hypot(center.x - p.x, center.y - p.y) > GRID.cell * 0.38) return null;
|
||||
const k = key(cell.col, cell.row);
|
||||
return game.conveyorTiles.has(k) ? k : null;
|
||||
}
|
||||
function addMarkerFromRoute(game, markers, route) {
|
||||
if (!route || route.length < 2) return;
|
||||
for (let i = 0; i < route.length - 1; i += 1) {
|
||||
const a = route[i], b = route[i + 1];
|
||||
const ak = cellKeyFromPoint(game, a);
|
||||
const bk = cellKeyFromPoint(game, b);
|
||||
if (!ak || !bk || ak === bk) continue;
|
||||
const ac = parseKey(ak), bc = parseKey(bk);
|
||||
const dc = bc.col - ac.col, dr = bc.row - ac.row;
|
||||
if (Math.abs(dc) + Math.abs(dr) !== 1) continue;
|
||||
const angle = Math.atan2(dr, dc);
|
||||
if (!markers.has(ak)) markers.set(ak, []);
|
||||
const list = markers.get(ak);
|
||||
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
|
||||
}
|
||||
const flowSegments = new Map();
|
||||
}
|
||||
function collectConveyorDirectionMarkers(game) {
|
||||
const markers = new Map();
|
||||
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
|
||||
for (const scanner of game.scanners) {
|
||||
for (const side of ['left', 'right']) {
|
||||
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id);
|
||||
if (!plan) continue;
|
||||
addRouteFlowSegments(flowSegments, plan.route, plan.destination);
|
||||
}
|
||||
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
|
||||
}
|
||||
drawDestinationFlowSegments(ctx, flowSegments);
|
||||
ctx.restore();
|
||||
return markers;
|
||||
}
|
||||
function drawRouteFlow(ctx, points, color, width) {
|
||||
if (!points || points.length < 2) return;
|
||||
function drawConveyorDirectionMarkers(ctx, center, angles) {
|
||||
const limited = angles.slice(0, 3);
|
||||
if (!limited.length) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.globalAlpha = .78; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color; ctx.globalAlpha = .92;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i], b = points[i + 1];
|
||||
const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
|
||||
if (dist < 36) continue;
|
||||
drawArrow(ctx, a.x + dx * .55, a.y + dy * .55, Math.atan2(dy, dx));
|
||||
}
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.globalAlpha = 0.86;
|
||||
limited.forEach((angle, index) => {
|
||||
const offset = (index - (limited.length - 1) / 2) * 4;
|
||||
const nx = Math.cos(angle + Math.PI / 2), ny = Math.sin(angle + Math.PI / 2);
|
||||
drawDirectionTriangle(ctx, center.x + nx * offset, center.y + ny * offset, angle, 6, 5);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
function flowSegmentKey(a, b) {
|
||||
const ak = `${Math.round(a.x)},${Math.round(a.y)}`;
|
||||
const bk = `${Math.round(b.x)},${Math.round(b.y)}`;
|
||||
return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`;
|
||||
}
|
||||
function addRouteFlowSegments(map, points, dest) {
|
||||
if (!points || points.length < 2) return;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i], b = points[i + 1];
|
||||
if (Math.hypot(b.x - a.x, b.y - a.y) < 8) continue;
|
||||
const k = flowSegmentKey(a, b);
|
||||
if (!map.has(k)) map.set(k, new Map());
|
||||
const dests = map.get(k);
|
||||
if (!dests.has(dest)) dests.set(dest, { a, b });
|
||||
}
|
||||
}
|
||||
function drawDestinationFlowSegments(ctx, segments) {
|
||||
const order = ['mixer', 'trash', 'truck', 'scanner'];
|
||||
for (const dests of segments.values()) {
|
||||
const entries = [...dests.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
|
||||
const count = entries.length;
|
||||
entries.forEach(([dest, seg], index) => {
|
||||
const offset = (index - (count - 1) / 2) * 8;
|
||||
drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset);
|
||||
});
|
||||
}
|
||||
}
|
||||
function drawColoredFlowSegment(ctx, a, b, color, offset) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < 8) return;
|
||||
const nx = -dy / dist, ny = dx / dist;
|
||||
const ax = a.x + nx * offset, ay = a.y + ny * offset;
|
||||
const bx = b.x + nx * offset, by = b.y + ny * offset;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.globalAlpha = .9; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.fillStyle = color; ctx.globalAlpha = .98;
|
||||
if (dist > 32) drawArrow(ctx, ax + (bx - ax) * .58, ay + (by - ay) * .58, Math.atan2(dy, dx));
|
||||
ctx.restore();
|
||||
}
|
||||
function drawArrow(ctx, x, y, angle) {
|
||||
|
||||
function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
|
||||
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
|
||||
ctx.beginPath(); ctx.moveTo(11, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2, 0); ctx.lineTo(-6, 7); ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(length, 0); ctx.lineTo(-length * 0.72, -halfWidth); ctx.lineTo(-length * 0.72, halfWidth); ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawScannerConnectors(ctx, game) {
|
||||
|
|
@ -219,14 +198,14 @@ function drawScannerConnectors(ctx, game) {
|
|||
for (const scanner of game.scanners) {
|
||||
const outputs = scannerOutputs(scanner);
|
||||
const items = [
|
||||
{ p: scannerConnector(scanner, 'inputA'), label: 'IN', color: THEME.green },
|
||||
{ p: scannerConnector(scanner, 'left'), label: destinationLabel(outputs.left), color: destinationColor(outputs.left) },
|
||||
{ p: scannerConnector(scanner, 'right'), label: destinationLabel(outputs.right), color: destinationColor(outputs.right) }
|
||||
{ type: 'inputA', p: scannerConnector(scanner, 'inputA'), label: 'IN', color: THEME.green },
|
||||
{ type: 'left', p: scannerConnector(scanner, 'left'), label: destinationLabel(outputs.left), color: destinationColor(outputs.left) },
|
||||
{ type: 'right', p: scannerConnector(scanner, 'right'), label: destinationLabel(outputs.right), color: destinationColor(outputs.right) }
|
||||
];
|
||||
for (const item of items) {
|
||||
if (!item.p) continue;
|
||||
const p = cellCenter(item.p.col, item.p.row);
|
||||
const connected = game.conveyorTiles.has(key(item.p.col, item.p.row));
|
||||
const connected = scannerPortHasConveyor(game, scanner, item.type || (item.label === 'IN' ? 'inputA' : null)) || game.conveyorTiles.has(key(item.p.col, item.p.row));
|
||||
ctx.fillStyle = connected ? item.color : '#f4c2c2';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true);
|
||||
|
|
@ -361,7 +340,7 @@ function drawMixer(ctx, game) {
|
|||
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
|
||||
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(game.mixerHalfTimer > 0 ? `HALF PAY ${game.mixerHalfTimer.toFixed(1)}s` : `PAY ¥5`, m.x + m.w / 2, m.y + 48);
|
||||
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
|
||||
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
|
||||
ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
|
||||
for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
|
||||
|
|
@ -387,7 +366,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 ${yen(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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from '../core/config.js';
|
||||
import { AUTO_SCANNER_COOLDOWN, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
|
||||
import { nextSpawnDelay, getSpawnRange } from '../core/state.js';
|
||||
import { createEggFarm, createScanner, createFacility, nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
|
||||
import { key, parseKey, clamp, pointToCell, cellCenter, distance, yen } from '../core/utils.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner } from './routing.js';
|
||||
import { spendCash, refundCash } from './economy.js';
|
||||
import { snapshot, record } from './history.js';
|
||||
import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
|
||||
import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js';
|
||||
import { TEXT, equipmentName } from '../core/text.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, upgradeCostFor, resaleValueFor } 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(); }
|
||||
|
|
@ -43,21 +45,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function selectedTitle(obj) {
|
||||
if (!obj) return 'Equipment';
|
||||
if (obj.type === 'eggFarm') return `Egg Farm #${obj.id}`;
|
||||
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? 'Auto' : 'Manual'} Scanner #${obj.id}`;
|
||||
if (obj.type === 'conveyor') return 'Conveyor Tile';
|
||||
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
|
||||
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
|
||||
if (obj.type === 'conveyor') return equipmentName('conveyor');
|
||||
if (obj.type === 'facility') return obj.name;
|
||||
return 'Equipment';
|
||||
}
|
||||
|
||||
function equipmentPrice(hit) {
|
||||
const obj = hit.ref || hit;
|
||||
const type = hit.type || obj.type;
|
||||
if (type === 'conveyor') return BUILD_COSTS.conveyor;
|
||||
if (type === 'eggFarm') return obj.price || BUILD_COSTS.eggFarm;
|
||||
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner);
|
||||
if (type === 'facility') return obj.price || FACILITY_PRICES[obj.id] || 300;
|
||||
return 0;
|
||||
return equipmentBasePrice(hit);
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
|
|
@ -66,22 +62,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
|
||||
function buildAtCell(cell) {
|
||||
if (!cell) return fail('Out of grid');
|
||||
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||
const { col, row } = cell;
|
||||
if (game.buildTool === 'conveyor') {
|
||||
if (game.conveyorTiles.has(key(col, row))) return fail('Cell occupied');
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail('Cell occupied');
|
||||
if (game.cash < BUILD_COSTS.conveyor) return fail('Not enough cash');
|
||||
if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied);
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
const cost = buildPrice('conveyor');
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
spendCash(game, BUILD_COSTS.conveyor);
|
||||
spendCash(game, cost);
|
||||
const k = key(col, row);
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: game.buildSession });
|
||||
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession });
|
||||
game.selected = { type: 'conveyor', id: k };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.conveyor)}`, THEME.ink);
|
||||
refreshRoutingAfterEdit(game);
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
return;
|
||||
}
|
||||
if (isEquipmentCell(col, row)) return fail('Cell occupied');
|
||||
if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
|
||||
if (game.buildTool === 'eggFarm') buildFarm(col, row);
|
||||
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
|
||||
else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto');
|
||||
|
|
@ -90,81 +88,88 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
function buildAtPoint(p) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
|
||||
if (['mixer', 'trash', 'truck'].includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||
}
|
||||
|
||||
function buildFarm(col, row) {
|
||||
if (game.cash < BUILD_COSTS.eggFarm) return fail('Not enough cash');
|
||||
const cost = buildPrice('eggFarm');
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
spendCash(game, BUILD_COSTS.eggFarm);
|
||||
spendCash(game, cost);
|
||||
const farm = createEggFarm(game, col, row);
|
||||
game.eggFarms.push(farm);
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'eggFarm', id: farm.id };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.eggFarm)}`, THEME.ink);
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function buildScanner(col, row, kind) {
|
||||
const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
const cost = kind === 'auto' ? buildPrice('autoScanner') : buildPrice('manualScanner');
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
const scanner = createScanner(game, col, row, kind);
|
||||
game.scanners.push(scanner);
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'scanner', id: scanner.id };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function buildFacility(p, id) {
|
||||
const cost = FACILITY_PRICES[id];
|
||||
if (game.facilities[id]) return fail('Facility already exists');
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
const cost = buildPrice(id);
|
||||
if (game.facilities[id]) return fail(TEXT.fail.facilityExists);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
const f = createFacility(game, id, p, cost);
|
||||
if (facilityOverlaps(f)) return fail('Facility overlap');
|
||||
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
game.facilities[id] = f;
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.selected = { type: 'facility', id };
|
||||
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function refundForSameBuild(meta, x, y) {
|
||||
if (!meta || meta.builtSession !== game.buildSession) return;
|
||||
const amount = meta.price || 0;
|
||||
if (amount > 0) {
|
||||
refundCash(game, amount);
|
||||
floating(game, x, y - 16, `REFUND +${yen(amount)}`, THEME.green);
|
||||
}
|
||||
function refundOrSell(hit, x, y) {
|
||||
const resale = resaleValueFor(hit, game);
|
||||
if (resale.amount <= 0) return;
|
||||
refundCash(game, resale.amount);
|
||||
const label = resale.sameBuild ? 'REFUND' : 'SOLD 50%';
|
||||
floating(game, x, y - 16, `${label} +${yen(resale.amount)}`, THEME.green);
|
||||
}
|
||||
|
||||
function eraseAtPoint(p) {
|
||||
const hit = equipmentAtPoint(p);
|
||||
if (!hit) return fail('Nothing to erase');
|
||||
if (!hit) return fail(TEXT.fail.nothingToErase);
|
||||
record(game);
|
||||
if (hit.type === 'conveyor') {
|
||||
const c = parseKey(hit.oldKey);
|
||||
const center = cellCenter(c.col, c.row);
|
||||
refundForSameBuild(game.conveyorMeta.get(hit.oldKey), center.x, center.y);
|
||||
refundOrSell(hit, center.x, center.y);
|
||||
game.conveyorTiles.delete(hit.oldKey);
|
||||
game.conveyorMeta.delete(hit.oldKey);
|
||||
eraseEffect(game, center.x, center.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'eggFarm') {
|
||||
const c = cellCenter(hit.ref.col, hit.ref.row);
|
||||
refundForSameBuild(hit.ref, c.x, c.y);
|
||||
refundOrSell(hit, c.x, c.y);
|
||||
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'scanner') {
|
||||
const c = scannerCenter(hit.ref);
|
||||
refundForSameBuild(hit.ref, c.x, c.y);
|
||||
refundOrSell(hit, c.x, c.y);
|
||||
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'facility') {
|
||||
const f = hit.ref;
|
||||
refundForSameBuild(f, f.x + f.w / 2, f.y + f.h / 2);
|
||||
refundOrSell(hit, f.x + f.w / 2, f.y + f.h / 2);
|
||||
delete game.facilities[f.id];
|
||||
eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
game.selected = null;
|
||||
game.multiSelected = [];
|
||||
|
|
@ -191,242 +196,17 @@ 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;
|
||||
return null;
|
||||
return upgradeCostFor(obj);
|
||||
}
|
||||
|
||||
function upgradeSelected() {
|
||||
const obj = selectedObject();
|
||||
const cost = selectedUpgradeCost(obj);
|
||||
if (!cost) return fail('Max level or no upgrade');
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
if (!cost) return fail(TEXT.fail.noUpgrade);
|
||||
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
obj.level += 1;
|
||||
|
|
@ -455,28 +235,75 @@ 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';
|
||||
ui.modalBody.innerHTML = `<p>Auto scanner types will be split later. Current type is <strong>Standard</strong>.</p><ul><li>Role 0: male left, everything else right.</li><li>Role 1: poop left, everything else right.</li><li>Cooldown: ${AUTO_SCANNER_COOLDOWN}s.</li></ul>`;
|
||||
ui.modalTitle.textContent = 'Auto Scanner';
|
||||
ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${AUTO_SCANNER_COOLDOWN}s.</li></ul>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
|
||||
const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
|
||||
const close = modalButton('Close', hideModal);
|
||||
ui.modalActions.append(r0, r1, close);
|
||||
ui.modal.classList.add('visible');
|
||||
ui.modalActions.append(r0, r1);
|
||||
positionEquipmentPopover(scanner);
|
||||
ui.modal.classList.add('visible', 'equipment-popover');
|
||||
}
|
||||
|
||||
function switchScannerRole() {
|
||||
const obj = selectedObject();
|
||||
if (!obj || obj.type !== 'scanner') return;
|
||||
if (obj.kind === 'auto') return showAutoScannerMenu(obj);
|
||||
record(game);
|
||||
obj.role = obj.role === 0 ? 1 : 0;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function 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 - 274);
|
||||
const maxY = Math.max(12, window.innerHeight - 220);
|
||||
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/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
const cost = selectedUpgradeCost(obj);
|
||||
if (cost) {
|
||||
const upgrade = modalButton(`Upgrade ${yen(cost)}`, () => { upgradeSelected(); hideModal(); }, 'primary-button');
|
||||
upgrade.disabled = game.cash < cost;
|
||||
if (upgrade.disabled) upgrade.title = TEXT.fail.notEnoughCash;
|
||||
ui.modalActions.appendChild(upgrade);
|
||||
}
|
||||
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
|
||||
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
|
||||
const resale = resaleValueFor(hit, game);
|
||||
const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`;
|
||||
ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger'));
|
||||
positionEquipmentPopover(obj);
|
||||
ui.modal.classList.add('visible', 'equipment-popover');
|
||||
}
|
||||
|
||||
function setBuildTool(tool) {
|
||||
if (game.phase !== 'build') return;
|
||||
game.buildTool = game.buildTool === tool ? null : tool;
|
||||
|
|
@ -494,7 +321,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
}
|
||||
for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
|
||||
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
|
||||
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: BUILD_COSTS.conveyor, x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
|
||||
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: buildPrice('conveyor'), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
|
||||
return boxes;
|
||||
}
|
||||
|
||||
|
|
@ -503,26 +330,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
if (obj.type === 'eggFarm') {
|
||||
const [min, max] = getSpawnRange(obj);
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
lines.push(`Level: ${obj.level}/4`);
|
||||
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
||||
} else if (obj.type === 'scanner') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
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`);
|
||||
|
||||
} else if (obj.type === 'conveyor') {
|
||||
const meta = game.conveyorMeta.get(obj.id);
|
||||
const comp = game.componentLookup?.get(obj.id);
|
||||
const c = comp ? game.congestion.get(comp) : null;
|
||||
lines.push(`Price: ${yen(BUILD_COSTS.conveyor)}`);
|
||||
lines.push(`Price: ${yen(buildPrice('conveyor'))}`);
|
||||
lines.push(`Cell: ${obj.id}`);
|
||||
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
|
||||
lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
|
||||
if (meta?.builtSession === game.buildSession) lines.push(`Same-build refund: ${yen(meta.price)}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
|
||||
} else if (obj.type === 'facility') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Level: ${obj.level}/3`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
|
||||
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 +369,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
|
||||
};
|
||||
|
|
|
|||
424
src/systems/chickSystem.js
Normal file
424
src/systems/chickSystem.js
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
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 { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice } 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 (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 = applyMixerPoopFine(game);
|
||||
countPoopDestination(game, 'mixer');
|
||||
countMistake(game);
|
||||
sludgeEffect(game, x, y);
|
||||
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
|
||||
onGameOverCheck();
|
||||
return;
|
||||
}
|
||||
const amount = applyMixerIncome(game);
|
||||
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 = applyTruckPoopFine(game);
|
||||
countPoopDestination(game, 'truck');
|
||||
if (isTarget) countCorrect(game);
|
||||
else countMistake(game);
|
||||
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 = applyWrongTruckFine(game);
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +1,24 @@
|
|||
import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TURN } from '../core/config.js';
|
||||
import { getSpawnRange } from '../core/state.js';
|
||||
import { applyRevenue } from './economy.js';
|
||||
|
||||
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 +72,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.
|
||||
|
|
@ -116,14 +113,14 @@ export function shouldFineMaleTruck(game) {
|
|||
return truckTarget(game) !== 'male';
|
||||
}
|
||||
|
||||
export function resolveContract(game, applyCashDelta) {
|
||||
export function resolveContract(game) {
|
||||
const contract = game.contractActive;
|
||||
if (!contract) return null;
|
||||
const count = targetTruckCount(game.stats, contract.target);
|
||||
const success = count >= contract.targetAmount;
|
||||
const bonus = success ? contract.reward : 0;
|
||||
if (bonus > 0) {
|
||||
applyCashDelta(game, bonus);
|
||||
applyRevenue(game, bonus);
|
||||
game.stats.contractBonus = bonus;
|
||||
game.totals.contractBonus += bonus;
|
||||
game.totals.contractSuccess += 1;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,91 @@
|
|||
import { MIXER_PRICE, TRUCK_PRICE, TRUCK_POOP_DECAY, MIXER_HALF_SECONDS } from '../core/config.js';
|
||||
import { factoryValue, targetTruckCount, truckTarget, isPoopSoilActive } from './contracts.js';
|
||||
import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } 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 equipmentBasePrice(objOrHit) {
|
||||
const obj = objOrHit?.ref || objOrHit || {};
|
||||
const type = objOrHit?.type || obj.type;
|
||||
if (type === 'conveyor') return FACILITY_DEFS.conveyor.price;
|
||||
if (type === 'eggFarm') return obj.price || FACILITY_DEFS.eggFarm.price;
|
||||
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price);
|
||||
if (type === 'facility') return obj.price || FACILITY_DEFS[obj.id]?.price || 0;
|
||||
return FACILITY_DEFS[obj.id]?.price || 0;
|
||||
}
|
||||
export function positivePayout(game, amount) {
|
||||
const base = Math.max(0, Math.floor(amount));
|
||||
return game.mixerHalfTimer > 0 ? Math.floor(base / 2) : base;
|
||||
|
||||
export function buildPrice(id) {
|
||||
return FACILITY_DEFS[id]?.price || 0;
|
||||
}
|
||||
|
||||
export function facilityUpgradeCount(game, id) {
|
||||
return Math.max(0, (game.facilities?.[id]?.level || 1) - 1);
|
||||
}
|
||||
|
||||
export function incomeMultiplier(game, id) {
|
||||
return INCOME_FACILITY_IDS.includes(id) ? Math.pow(ECONOMY.incomeUpgradeRate, facilityUpgradeCount(game, id)) : 1;
|
||||
}
|
||||
|
||||
export function upgradedMixerPrice(game) {
|
||||
return Math.ceil(ECONOMY.income.mixer * incomeMultiplier(game, 'mixer'));
|
||||
}
|
||||
|
||||
export function upgradedTruckPrice(game) {
|
||||
return Math.ceil(ECONOMY.income.truck * incomeMultiplier(game, 'truck'));
|
||||
}
|
||||
|
||||
export function mixerPoopPenalty(game) {
|
||||
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer'));
|
||||
}
|
||||
|
||||
export function truckPoopPenalty(game) {
|
||||
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck'));
|
||||
}
|
||||
|
||||
export function maleTruckPenalty(game) {
|
||||
return Math.floor(30 * (game.turn / 2));
|
||||
const baseFine = ECONOMY.maleTruckFinePerHalfDay * (game.turn / 2);
|
||||
return Math.ceil(baseFine * incomeMultiplier(game, 'truck'));
|
||||
}
|
||||
|
||||
export function zundaTaxInfo(cash) {
|
||||
const rules = ECONOMY.zundaTax;
|
||||
const profit = Math.max(0, Math.floor(Number(cash) || 0));
|
||||
const taxable = Math.max(0, profit - rules.exemption);
|
||||
if (taxable <= 0) return { profit, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0 };
|
||||
const step = Math.max(1, Math.ceil(taxable / rules.stepAmount));
|
||||
const rate = Math.min(rules.maxRate, step * rules.stepRate);
|
||||
const tax = Math.ceil(taxable * rate);
|
||||
return { profit, taxable, rate, ratePercent: Math.round(rate * 100), tax, step };
|
||||
}
|
||||
|
||||
export function zundaTaxForCash(cash) {
|
||||
return zundaTaxInfo(cash).tax;
|
||||
}
|
||||
|
||||
export function resaleValueFor(hit, game) {
|
||||
const obj = hit?.ref || hit || {};
|
||||
const meta = hit?.type === 'conveyor' ? game?.conveyorMeta?.get(hit.oldKey || obj.id) : obj;
|
||||
const price = meta?.price || equipmentBasePrice(hit || obj);
|
||||
const sameBuild = meta?.builtSession === game?.buildSession;
|
||||
return { amount: Math.max(0, Math.ceil(price * (sameBuild ? 1 : 0.5))), sameBuild, price };
|
||||
}
|
||||
|
||||
export function explosionDamageForPrice(price) {
|
||||
return Math.max(1, Math.ceil((Number(price) || 0) / ECONOMY.explosionDamageDivisor));
|
||||
}
|
||||
|
||||
export function upgradeCostFor(obj) {
|
||||
if (!obj) return null;
|
||||
if (obj.type === 'eggFarm') return FACILITY_DEFS.eggFarm.upgradeCosts[obj.level] || null;
|
||||
if (obj.type === 'facility' && INCOME_FACILITY_IDS.includes(obj.id)) {
|
||||
const level = obj.level || 1;
|
||||
if (level <= 1) return 300;
|
||||
return Math.ceil(900 * Math.pow(1.65, level - 2));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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,13 +94,23 @@ 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;
|
||||
game.totals.profit -= amount;
|
||||
game.stats.penalty += amount;
|
||||
game.totals.penalty += amount;
|
||||
|
||||
export function applyRevenue(game, amount) {
|
||||
const delta = Math.max(0, Math.ceil(amount));
|
||||
if (delta > 0) applyCashDelta(game, delta);
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function applyPenalty(game, amount) {
|
||||
const delta = Math.max(0, Math.ceil(amount));
|
||||
if (delta > 0) applyCashDelta(game, -delta);
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function spendCash(game, amount) {
|
||||
applyPenalty(game, amount);
|
||||
}
|
||||
|
||||
export function refundCash(game, amount) {
|
||||
if (amount <= 0) return;
|
||||
game.cash += amount;
|
||||
|
|
@ -33,27 +118,84 @@ export function refundCash(game, amount) {
|
|||
game.totals.profit += amount;
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
}
|
||||
|
||||
export function applyMixerIncome(game) {
|
||||
const amount = upgradedMixerPrice(game);
|
||||
game.stats.mixerRevenue += amount;
|
||||
applyRevenue(game, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function applyMixerPoopFine(game) {
|
||||
const amount = mixerPoopPenalty(game);
|
||||
game.stats.mixerPoopFine += amount;
|
||||
game.totals.mixerPoopFine += amount;
|
||||
applyPenalty(game, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function applyTruckPoopFine(game) {
|
||||
const amount = truckPoopPenalty(game);
|
||||
game.stats.truckPoopFine += amount;
|
||||
game.totals.truckPoopFine += amount;
|
||||
applyPenalty(game, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function applyWrongTruckFine(game) {
|
||||
const amount = maleTruckPenalty(game);
|
||||
game.stats.maleTruckFine += amount;
|
||||
game.totals.maleTruckFine += amount;
|
||||
applyPenalty(game, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
export function applyExplosionDamage(game, price) {
|
||||
const amount = explosionDamageForPrice(price);
|
||||
game.stats.explosionDamage += amount;
|
||||
game.totals.explosionDamage += amount;
|
||||
applyPenalty(game, amount);
|
||||
return amount;
|
||||
}
|
||||
|
||||
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;
|
||||
if (adjusted > 0) applyCashDelta(game, adjusted);
|
||||
return { base, soilMultiplier, adjusted, target, targetCount };
|
||||
game.stats.truckRevenue = adjusted;
|
||||
if (adjusted > 0) applyRevenue(game, adjusted);
|
||||
return { base, adjusted, target, targetCount, unitPrice };
|
||||
}
|
||||
|
||||
export function collectZundaTax(game, basisCash = game.cash) {
|
||||
const info = zundaTaxInfo(basisCash);
|
||||
if (info.tax <= 0) return info;
|
||||
game.stats.zundaTax += info.tax;
|
||||
game.totals.zundaTax += info.tax;
|
||||
applyPenalty(game, info.tax);
|
||||
return info;
|
||||
}
|
||||
|
||||
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 const MIXER_PRICE = ECONOMY.income.mixer;
|
||||
export const TRUCK_PRICE = ECONOMY.income.truck;
|
||||
|
|
|
|||
11
src/systems/effects.js
vendored
11
src/systems/effects.js
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import { EFFECT_PRIORITY, THEME } from '../core/config.js';
|
||||
import { randomBetween } from '../core/utils.js';
|
||||
import { applyCashDelta } from './economy.js';
|
||||
import { randomBetween, yen } from '../core/utils.js';
|
||||
import { applyExplosionDamage } from './economy.js';
|
||||
|
||||
export function floating(game, x, y, text, color = THEME.ink) {
|
||||
game.floatingTexts.push({ x, y, text, color, life: 1, maxLife: 1 });
|
||||
|
|
@ -81,11 +81,8 @@ 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));
|
||||
applyCashDelta(game, -damage);
|
||||
game.stats.explosionDamage += damage;
|
||||
game.totals.explosionDamage += damage;
|
||||
floating(game, e.x, e.y - 10, `-${damage}`, THEME.danger);
|
||||
const damage = applyExplosionDamage(game, box.price);
|
||||
floating(game, e.x, e.y - 10, `-${yen(damage)}`, THEME.danger);
|
||||
shockwave(game, e.x, e.y, THEME.danger, 30);
|
||||
sparkBurst(game, e.x, e.y, 5, THEME.danger);
|
||||
shake(game, 5, 0.18);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,114 @@
|
|||
import { DIRS, GRID } from '../core/config.js';
|
||||
import { DIRS, GRID, MACHINE_FACILITY_IDS } from '../core/config.js';
|
||||
import { routeLabel } from '../core/text.js';
|
||||
import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Object lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); }
|
||||
export function scannerConnector(scanner, type) {
|
||||
return {
|
||||
inputA: { col: scanner.col, row: scanner.row - 1 },
|
||||
// Bottom input is intentionally disabled. Scanners now have one input on top
|
||||
// and two outputs on the left/right, which makes junction direction easier to read.
|
||||
inputB: null,
|
||||
left: { col: scanner.col - 1, row: scanner.row },
|
||||
right: { col: scanner.col + 1, row: scanner.row }
|
||||
}[type];
|
||||
}
|
||||
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
|
||||
export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; }
|
||||
export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; }
|
||||
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Port definitions
|
||||
// -----------------------------------------------------------------------------
|
||||
export function scannerConnector(scanner, type) {
|
||||
return {
|
||||
inputA: { col: scanner.col, row: scanner.row - 1 },
|
||||
inputB: null,
|
||||
left: { col: scanner.col - 1, row: scanner.row },
|
||||
right: { col: scanner.col + 1, row: scanner.row }
|
||||
}[type];
|
||||
}
|
||||
|
||||
function adjacentCells(point) {
|
||||
if (!point) return [];
|
||||
return DIRS.map(d => ({ col: point.col + d.dc, row: point.row + d.dr })).filter(p => inGrid(p.col, p.row));
|
||||
}
|
||||
|
||||
function conveyorAt(game, point) {
|
||||
return !!point && inGrid(point.col, point.row) && game.conveyorTiles.has(key(point.col, point.row));
|
||||
}
|
||||
|
||||
function exactConveyorCell(game, point) {
|
||||
return conveyorAt(game, point) ? [{ ...point }] : [];
|
||||
}
|
||||
|
||||
// Kept for explicit non-port use. Ports and validation intentionally use exact
|
||||
// cells only; visual adjacency is not treated as a connection.
|
||||
export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferred = []) {
|
||||
if (!point || !inGrid(point.col, point.row)) return [];
|
||||
const result = [];
|
||||
const add = p => {
|
||||
if (!p || !inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) return;
|
||||
if (blocked.some(b => b && sameCell(p, b))) return;
|
||||
if (!result.some(x => sameCell(x, p))) result.push({ ...p, viaTolerance: !sameCell(p, point) });
|
||||
};
|
||||
add(point);
|
||||
for (const p of preferred) add(p);
|
||||
for (const p of adjacentCells(point)) add(p);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Strict connection tests
|
||||
// -----------------------------------------------------------------------------
|
||||
export function scannerInputCells(game, scanner) {
|
||||
return exactConveyorCell(game, scannerConnector(scanner, 'inputA'));
|
||||
}
|
||||
|
||||
function outputStartCells(game, scanner, side) {
|
||||
return exactConveyorCell(game, scannerConnector(scanner, side));
|
||||
}
|
||||
|
||||
export function facilityEntryPoint(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f) return null;
|
||||
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
|
||||
if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
|
||||
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
}
|
||||
|
||||
export function facilityEntryCell(game, dest) {
|
||||
return game.facilities[dest]?.entry || null;
|
||||
}
|
||||
|
||||
export function facilityEndpointCells(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f?.entry) return [];
|
||||
return exactConveyorCell(game, f.entry);
|
||||
}
|
||||
|
||||
export function portHasConveyor(game, port) {
|
||||
return exactConveyorCell(game, port).length > 0;
|
||||
}
|
||||
|
||||
export function scannerPortHasConveyor(game, scanner, type) {
|
||||
if (type === 'inputA') return scannerInputCells(game, scanner).length > 0;
|
||||
return outputStartCells(game, scanner, type).length > 0;
|
||||
}
|
||||
|
||||
export function getInputScanner(game, col, row) {
|
||||
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) {
|
||||
return game.scanners.some(scanner => sameCell({ col, row }, scannerConnector(scanner, 'left')) || sameCell({ col, row }, scannerConnector(scanner, 'right')));
|
||||
}
|
||||
export function inputCellsForScanner(game, scanner) { return scannerInputCells(game, scanner); }
|
||||
|
||||
export function refreshRoutingAfterEdit(game) {
|
||||
game.branchCounters?.clear?.();
|
||||
game.routeCache = null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Conveyor graph and pathfinding
|
||||
// -----------------------------------------------------------------------------
|
||||
export function getConveyorNeighbors(game, col, row) {
|
||||
const result = [];
|
||||
for (const d of DIRS) {
|
||||
|
|
@ -25,45 +117,23 @@ export function getConveyorNeighbors(game, col, row) {
|
|||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getAdjacentConveyors(game, col, row) {
|
||||
return DIRS.map(d => ({ col: col + d.dc, row: row + d.dr }))
|
||||
.filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
|
||||
}
|
||||
export function getInputScanner(game, col, row) {
|
||||
return game.scanners.find(scanner => {
|
||||
const a = scannerConnector(scanner, 'inputA');
|
||||
return a && sameCell({ col, row }, a);
|
||||
}) || null;
|
||||
}
|
||||
export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); }
|
||||
export function isOutputConnector(game, col, row) {
|
||||
return game.scanners.some(scanner => {
|
||||
const l = scannerConnector(scanner, 'left');
|
||||
const r = scannerConnector(scanner, 'right');
|
||||
return sameCell({ col, row }, l) || sameCell({ col, row }, r);
|
||||
});
|
||||
}
|
||||
export function inputCellsForScanner(game, scanner) {
|
||||
return ['inputA'].map(t => scannerConnector(scanner, t))
|
||||
.filter(p => p && inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
|
||||
}
|
||||
export function routePoints(cells, extraEnd = null) {
|
||||
const pts = cells.map(p => cellCenter(p.col, p.row));
|
||||
if (extraEnd) pts.push(extraEnd);
|
||||
return pts;
|
||||
return adjacentCells({ col, row }).filter(p => game.conveyorTiles.has(key(p.col, p.row)));
|
||||
}
|
||||
|
||||
function dirBetween(a, b) {
|
||||
const dc = b.col - a.col, dr = b.row - a.row;
|
||||
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
|
||||
}
|
||||
|
||||
function isCross(game, col, row) {
|
||||
const ns = getConveyorNeighbors(game, col, row);
|
||||
const names = new Set(ns.map(n => n.dir.name));
|
||||
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
|
||||
}
|
||||
|
||||
// Routing rule: a four-way cross conveyor is an overpass/crossing. Chicks never turn there.
|
||||
// The BFS state includes the incoming direction so every route that enters a cross must leave straight.
|
||||
// A four-way cross conveyor is an overpass/crossing. Chicks never turn there.
|
||||
export function bfsAllRoutes(game, start, isGoal) {
|
||||
const startK = key(start.col, start.row);
|
||||
if (!game.conveyorTiles.has(startK)) return [];
|
||||
|
|
@ -100,137 +170,6 @@ export function bfsAllRoutes(game, start, isGoal) {
|
|||
});
|
||||
}
|
||||
|
||||
export function chooseRoundRobin(game, id, candidates, advance = false) {
|
||||
if (!candidates.length) return null;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const ka = a.key || JSON.stringify(a.route || a.cells || a);
|
||||
const kb = b.key || JSON.stringify(b.route || b.cells || b);
|
||||
return ka.localeCompare(kb);
|
||||
});
|
||||
const n = game.branchCounters.get(id) || 0;
|
||||
if (advance) game.branchCounters.set(id, n + 1);
|
||||
return sorted[n % sorted.length];
|
||||
}
|
||||
|
||||
export function scannerOutputs(scanner) {
|
||||
if (scanner.role === 0) return { left: 'mixer', right: 'scanner-role-1' };
|
||||
return { left: 'trash', right: 'truck' };
|
||||
}
|
||||
export function destinationLabel(dest) {
|
||||
return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase();
|
||||
}
|
||||
export function destinationColor(dest) {
|
||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
}
|
||||
|
||||
export function facilityEntryPoint(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f) return null;
|
||||
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
|
||||
// Legacy fallback for older save data. New facilities use fixed receiver cells on the grid edge.
|
||||
if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
|
||||
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
}
|
||||
export function facilityEntryCell(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
return f?.entry || null;
|
||||
}
|
||||
|
||||
export function facilityEndpointCells(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f?.entry) return [];
|
||||
const entry = f.entry;
|
||||
const exactKey = key(entry.col, entry.row);
|
||||
if (game.conveyorTiles.has(exactKey)) return [{ ...entry }];
|
||||
// v10.4 connection tolerance: if the receiver port was moved to the grid edge
|
||||
// but the player left the belt one cell next to it, treat that adjacent belt as
|
||||
// the endpoint. This keeps moved scanners/conveyors/edge ports from appearing
|
||||
// visually correct but failing to connect.
|
||||
const adjacent = [];
|
||||
for (const d of DIRS) {
|
||||
const p = { col: entry.col + d.dc, row: entry.row + d.dr };
|
||||
if (inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))) adjacent.push(p);
|
||||
}
|
||||
return adjacent;
|
||||
}
|
||||
|
||||
export function isFacilityEndpoint(game, p, dest, connector) {
|
||||
if (sameCell(p, connector)) return false;
|
||||
const f = game.facilities[dest];
|
||||
// New facilities only route into their explicit receiver port, or one adjacent
|
||||
// belt tile when the receiver itself has no tile. Arbitrary dead ends never
|
||||
// auto-become machine exits.
|
||||
if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
|
||||
const degree = getConveyorNeighbors(game, p.col, p.row).length;
|
||||
if (degree > 1) return false;
|
||||
const entry = facilityEntryPoint(game, dest);
|
||||
if (!entry) return false;
|
||||
return distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
const starts = getAdjacentConveyors(game, farm.col, farm.row).filter(p => !isOutputConnector(game, p.col, p.row));
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
|
||||
for (const cells of routes) {
|
||||
const last = cells[cells.length - 1];
|
||||
const scanner = getInputScanner(game, last.col, last.row);
|
||||
if (!scanner) continue;
|
||||
candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
|
||||
}
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) };
|
||||
}
|
||||
|
||||
export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
|
||||
const scanner = scannerById(game, scannerId);
|
||||
if (!scanner) return null;
|
||||
const connector = scannerConnector(scanner, side);
|
||||
if (!connector || !game.conveyorTiles.has(key(connector.col, connector.row))) return null;
|
||||
const dest = scannerOutputs(scanner)[side];
|
||||
if (dest === 'scanner-role-1') {
|
||||
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
|
||||
const candidates = [];
|
||||
for (const target of targets) {
|
||||
const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row)));
|
||||
const routes = bfsAllRoutes(game, connector, p => inputKeys.has(key(p.col, p.row)));
|
||||
for (const cells of routes) candidates.push({ key: `s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { destination: 'scanner', nextScannerId: chosen.target.id, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, scannerCenter(chosen.target))] };
|
||||
}
|
||||
const routes = bfsAllRoutes(game, connector, p => isFacilityEndpoint(game, p, dest, connector));
|
||||
const candidates = routes.map(cells => ({ key: `${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells }));
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { destination: dest, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, facilityEntryPoint(game, dest))] };
|
||||
}
|
||||
|
||||
export function facilityConnectionIssues(game) {
|
||||
const issues = [];
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
|
||||
for (const id of ['mixer', 'trash', 'truck']) {
|
||||
const f = game.facilities[id];
|
||||
if (!f) {
|
||||
issues.push(`${labels[id]} is missing`);
|
||||
continue;
|
||||
}
|
||||
const endpoints = facilityEndpointCells(game, id);
|
||||
if (!endpoints.length) issues.push(`${labels[id]} receiver has no conveyor`);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function factoryReady(game) {
|
||||
return facilityConnectionIssues(game).length === 0;
|
||||
}
|
||||
|
||||
export function buildConveyorComponents(game) {
|
||||
const components = new Map();
|
||||
const cellToComponent = new Map();
|
||||
|
|
@ -268,3 +207,167 @@ export function nearestConveyorKey(game, x, y) {
|
|||
}
|
||||
return bestD <= GRID.cell * 1.1 ? best : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Route planning
|
||||
// -----------------------------------------------------------------------------
|
||||
export function routePoints(cells, extraEnd = null) {
|
||||
const pts = cells.map(p => cellCenter(p.col, p.row));
|
||||
if (extraEnd) pts.push(extraEnd);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function appendPoint(points, p) {
|
||||
if (!p) return;
|
||||
if (!points.length || distance(points[points.length - 1], p) > 1) points.push(p);
|
||||
}
|
||||
|
||||
function routeWithConnector(fromPoint, connector, cells, extraEnd = null) {
|
||||
const points = [{ x: fromPoint.x, y: fromPoint.y }];
|
||||
appendPoint(points, cellCenter(connector.col, connector.row));
|
||||
for (const p of routePoints(cells, extraEnd)) appendPoint(points, p);
|
||||
return points;
|
||||
}
|
||||
|
||||
export function chooseRoundRobin(game, id, candidates, advance = false) {
|
||||
if (!candidates.length) return null;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const ka = a.key || JSON.stringify(a.route || a.cells || a);
|
||||
const kb = b.key || JSON.stringify(b.route || b.cells || b);
|
||||
return ka.localeCompare(kb);
|
||||
});
|
||||
const n = game.branchCounters.get(id) || 0;
|
||||
if (advance) game.branchCounters.set(id, n + 1);
|
||||
return sorted[n % sorted.length];
|
||||
}
|
||||
|
||||
export function 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 scannerOutputs(scanner) {
|
||||
const rules = scannerRules(scanner);
|
||||
return { left: rules.left.destination, right: rules.right.destination };
|
||||
}
|
||||
|
||||
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 routeLabel(dest); }
|
||||
export function destinationColor(dest) {
|
||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
}
|
||||
|
||||
export function isFacilityEndpoint(game, p, dest, connector) {
|
||||
if (sameCell(p, connector)) return false;
|
||||
const f = game.facilities[dest];
|
||||
if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
|
||||
const degree = getConveyorNeighbors(game, p.col, p.row).length;
|
||||
if (degree > 1) return false;
|
||||
const entry = facilityEntryPoint(game, dest);
|
||||
return !!entry && distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
const starts = getAdjacentConveyors(game, farm.col, farm.row);
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
|
||||
for (const cells of routes) {
|
||||
const last = cells[cells.length - 1];
|
||||
const scanner = getInputScanner(game, last.col, last.row);
|
||||
if (!scanner) continue;
|
||||
candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
|
||||
}
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance);
|
||||
if (chosen) return { 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);
|
||||
const starts = outputStartCells(game, scanner, side);
|
||||
if (!connector || !starts.length) return null;
|
||||
const dest = scannerOutputs(scanner)[side];
|
||||
if (dest === 'scanner-role-1') return routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance);
|
||||
return routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance);
|
||||
}
|
||||
|
||||
function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance) {
|
||||
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
|
||||
const candidates = [];
|
||||
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: routeWithConnector(fromPoint, connector, chosen.cells, scannerCenter(chosen.target)) };
|
||||
}
|
||||
|
||||
function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) {
|
||||
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 chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { destination: dest, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, dest)) };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Connection validation
|
||||
// -----------------------------------------------------------------------------
|
||||
function scannerName(scanner) {
|
||||
if (scanner.kind === 'manual') return `S${(scanner.slot ?? 0) + 1}`;
|
||||
return `Auto Scanner #${scanner.id}`;
|
||||
}
|
||||
|
||||
export function facilityConnectionIssues(game) {
|
||||
const issues = [];
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
|
||||
for (const id of MACHINE_FACILITY_IDS) {
|
||||
const f = game.facilities[id];
|
||||
if (!f) {
|
||||
issues.push(`${labels[id] || id} is missing`);
|
||||
continue;
|
||||
}
|
||||
if (!facilityEndpointCells(game, id).length) issues.push(`${labels[id] || id} receiver has no conveyor`);
|
||||
}
|
||||
for (const scanner of game.scanners) {
|
||||
if (!scannerPortHasConveyor(game, scanner, 'inputA')) issues.push(`${scannerName(scanner)} input has no conveyor`);
|
||||
if (!scannerPortHasConveyor(game, scanner, 'left')) issues.push(`${scannerName(scanner)} left output has no conveyor`);
|
||||
if (!scannerPortHasConveyor(game, scanner, 'right')) issues.push(`${scannerName(scanner)} right output has no conveyor`);
|
||||
for (const side of ['left', 'right']) {
|
||||
if (scannerPortHasConveyor(game, scanner, side) && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) {
|
||||
issues.push(`${scannerName(scanner)} ${side} route is incomplete`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const farm of game.eggFarms) {
|
||||
if (!routeFromFarmToScanner(game, farm)) issues.push(`Egg Farm #${farm.id} has no scanner route`);
|
||||
}
|
||||
return [...new Set(issues)];
|
||||
}
|
||||
|
||||
export function factoryReady(game) {
|
||||
return facilityConnectionIssues(game).length === 0;
|
||||
}
|
||||
|
|
|
|||
250
src/systems/selectionSystem.js
Normal file
250
src/systems/selectionSystem.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { GRID } from '../core/config.js';
|
||||
import { key, parseKey, cellCenter } from '../core/utils.js';
|
||||
import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
|
||||
import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { snapshot } from './history.js';
|
||||
import { buildPrice } from './economy.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: buildPrice('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: buildPrice('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;
|
||||
refreshRoutingAfterEdit(game);
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function finishGroupDrag() {
|
||||
if (!game.groupDrag) return;
|
||||
refreshRoutingAfterEdit(game);
|
||||
game.groupDrag = null;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
return {
|
||||
selectionFromHit,
|
||||
startSelectionBox,
|
||||
updateSelectionBox,
|
||||
finishSelectionBox,
|
||||
startGroupDrag,
|
||||
updateGroupDrag,
|
||||
finishGroupDrag
|
||||
};
|
||||
}
|
||||
45
src/systems/stats.js
Normal file
45
src/systems/stats.js
Normal 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;
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, CONTRACT_EVENT_FIRST_TURN, STARTING_CASH, VERSION } from '../core/config.js';
|
||||
import { BUILD_TOOL_IDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
|
||||
import { yen } from '../core/utils.js';
|
||||
import { TEXT } from '../core/text.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 { buildPrice, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } 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();
|
||||
|
|
@ -19,14 +19,32 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
|
|||
|
||||
function updateToolButtons() {
|
||||
for (const [name, btn] of Object.entries(ui.buttons)) {
|
||||
if (btn?.classList?.contains('tool-button')) btn.classList.toggle('active', game.buildTool === name || (name === 'trash' && game.buildTool === 'trash'));
|
||||
if (!btn?.classList?.contains('tool-button')) continue;
|
||||
btn.classList.toggle('active', game.buildTool === name);
|
||||
}
|
||||
for (const id of BUILD_TOOL_IDS) {
|
||||
const btn = ui.buttons[id];
|
||||
if (!btn) continue;
|
||||
const price = buildPrice(id);
|
||||
const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id];
|
||||
const unaffordable = game.cash < price;
|
||||
btn.disabled = game.phase !== 'build' || unaffordable || uniqueAlreadyBuilt;
|
||||
if (btn.disabled && game.buildTool === id) game.buildTool = null;
|
||||
btn.classList.toggle('unaffordable', unaffordable);
|
||||
btn.classList.toggle('already-built', uniqueAlreadyBuilt);
|
||||
btn.title = unaffordable
|
||||
? `Need ${yen(price - game.cash)} more`
|
||||
: uniqueAlreadyBuilt
|
||||
? `${FACILITY_DEFS[id]?.name || id} already exists`
|
||||
: '';
|
||||
}
|
||||
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build';
|
||||
}
|
||||
|
||||
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.';
|
||||
? TEXT.status.build(game.buildTool)
|
||||
: TEXT.status.sorting;
|
||||
}
|
||||
|
||||
function updateHistoryButtons() {
|
||||
|
|
@ -37,10 +55,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 tax = zundaTaxInfo(game.cash);
|
||||
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>`,
|
||||
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">All receiver ports connected.</span>`
|
||||
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
|
||||
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}%`,
|
||||
`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">${TEXT.status.allPortsConnected}</span>`
|
||||
].join('<br>');
|
||||
}
|
||||
|
||||
|
|
@ -48,14 +69,14 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
|
|||
if (!ui.contractPanel) return;
|
||||
const offer = game.contractOffer;
|
||||
if (game.phase !== 'build') {
|
||||
ui.contractPanel.innerHTML = 'Forced events appear in Build phase.';
|
||||
ui.contractPanel.innerHTML = TEXT.status.eventPanelRunning;
|
||||
return;
|
||||
}
|
||||
if (!offer) {
|
||||
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 1–7. 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 +84,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,28 +98,17 @@ 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 = TEXT.status.equipmentPanelEmpty;
|
||||
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() {
|
||||
updateToolButtons();
|
||||
ui.shell.classList.toggle('phase-running', game.phase === 'running');
|
||||
ui.shell.classList.toggle('phase-build', game.phase === 'build');
|
||||
ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover');
|
||||
|
|
@ -107,8 +117,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);
|
||||
|
|
@ -122,6 +134,8 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
|
|||
ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0);
|
||||
ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1);
|
||||
ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1);
|
||||
const nextTax = zundaTaxInfo(game.cash).tax;
|
||||
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA TAX ${yen(nextTax)}` : TEXT.actions.nextDay;
|
||||
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game);
|
||||
}
|
||||
|
||||
|
|
@ -130,17 +144,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';
|
||||
|
|
@ -149,22 +163,23 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
|
|||
}
|
||||
|
||||
function phaseLabel() {
|
||||
if (game.phase === 'running') return game.timeLeft <= 0 ? 'Clearing' : 'Sorting';
|
||||
if (game.phase === 'build') return 'Build';
|
||||
if (game.phase === 'gameover') return 'Game Over';
|
||||
return 'Title';
|
||||
if (game.phase === 'running') return game.timeLeft <= 0 ? TEXT.phases.clearing : TEXT.phases.running;
|
||||
if (game.phase === 'build') return TEXT.phases.build;
|
||||
if (game.phase === 'gameover') return TEXT.phases.gameover;
|
||||
return TEXT.phases.title;
|
||||
}
|
||||
|
||||
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.modalTitle.textContent = TEXT.versionTitle;
|
||||
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.modalActions.appendChild(button(TEXT.actions.startGame, 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 +187,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>ZUNDA TAX</strong><span>Opening tax paid on Next Day: -${yen(r.zundaTax || 0)}</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.modalActions.appendChild(button(TEXT.actions.buildPhase, hideModal, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover');
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
|
|
@ -196,10 +216,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
|
|||
const processed = game.totals.processed;
|
||||
const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0;
|
||||
const fs = finalScore(game);
|
||||
ui.modalTitle.textContent = 'Game Over';
|
||||
ui.modalBody.innerHTML = `<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.modalTitle.textContent = TEXT.phases.gameover;
|
||||
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>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</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.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button'));
|
||||
ui.modal.classList.remove('equipment-popover');
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +235,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) {
|
||||
|
|
|
|||
75
styles.css
75
styles.css
|
|
@ -24,7 +24,7 @@ h1, h2, p { margin: 0; }
|
|||
.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); }
|
||||
.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); }
|
||||
.hud-card.primary span { font-size: 10px; }
|
||||
.hud-card.primary span { font-size: 9px; }
|
||||
.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); }
|
||||
.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); }
|
||||
.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
|
||||
|
|
@ -43,8 +43,8 @@ h1, h2, p { margin: 0; }
|
|||
.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; }
|
||||
.panel-head { display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
.panel-head h1 { font-size: 15px; letter-spacing: .06em; }
|
||||
.panel-head h1 span { font-size: 10px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
|
||||
.panel-head p { color: var(--muted); margin-top: 4px; font-size: 10px; line-height: 1.25; }
|
||||
.panel-head h1 span { font-size: 9px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
|
||||
.panel-head p { color: var(--muted); margin-top: 4px; font-size: 9px; line-height: 1.25; }
|
||||
.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }
|
||||
.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
|
|
@ -59,10 +59,10 @@ h1, h2, p { margin: 0; }
|
|||
.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; }
|
||||
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; }
|
||||
.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
|
||||
.facility-card h3 { color: var(--ink); font-size: 14px; margin: 0 0 6px; }
|
||||
.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; }
|
||||
.facility-card p { margin: 4px 0; }
|
||||
.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.facility-action { padding: 7px 9px; font-size: 10px; }
|
||||
.facility-action { padding: 7px 9px; font-size: 9px; }
|
||||
.facility-action.good { background: var(--green-soft); }
|
||||
.facility-action.warn { background: #fff0d1; }
|
||||
.facility-action.danger { background: #ffdada; }
|
||||
|
|
@ -115,5 +115,68 @@ h1, h2, p { margin: 0; }
|
|||
.result-section-title:first-child { margin-top: 0; }
|
||||
.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
|
||||
|
||||
.compact-rules .mini-box { padding: 8px; font-size: 10px; }
|
||||
.compact-rules .mini-box { padding: 8px; font-size: 9px; }
|
||||
.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(250px, calc(100vw - 24px));
|
||||
max-height: min(280px, calc(100vh - 24px));
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
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: 5px 7px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.equipment-menu-lines.compact {
|
||||
padding: 7px;
|
||||
margin: 6px 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
.formula-box.compact {
|
||||
padding: 7px;
|
||||
margin: 6px 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue