diff --git a/README.md b/README.md index 90ddd52..eb0daf6 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,25 @@ -# Chick Sorter v11.0 Routing / Compact Upgrades +# 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`. -## v11.0 changes - -- 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 - -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. diff --git a/index.html b/index.html index 44a21b6..1e94edb 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v11.0 Routing / Compact Upgrades + Chick Sorter v14.0 Zunda Tax / Resale @@ -12,9 +12,9 @@
-
CASHJPY 250
+
CASH¥250
TIME60.0s
-
NETJPY 0
+
NET¥0
DAY1
PHASETitle
@@ -35,7 +35,7 @@
-

CHICK SORTER v11.0

+

CHICK SORTER v14.0

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

@@ -45,13 +45,13 @@

Add / Edit Equipment

- - - - - - - + + + + + + +
@@ -92,6 +92,6 @@
- + diff --git a/src/core/config.js b/src/core/config.js index 0ceaecf..fe441e5 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,41 +1,78 @@ -export const VERSION = 'v11.0 Routing / Compact Upgrades'; +export const VERSION = 'v14.0 Zunda Tax / Resale'; export const TURN_SECONDS = 60; 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.00; -export const CONVEYOR_SPEED_MAX = 300; -export const POOP_FINE = 10; -export const FAIRIES_FEE_PER_DAY = 10; -export const INCOME_UPGRADE_RATE = 1.05; +export const 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: 300, - 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' }, diff --git a/src/core/entities.js b/src/core/entities.js index b3f99b5..080a343 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -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, diff --git a/src/core/state.js b/src/core/state.js index 1c8f67b..4db4271 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,4 +1,4 @@ -import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_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() { @@ -18,7 +18,7 @@ export function newTurnStats() { pendingTruckRevenue: 0, mixerRevenue: 0, truckRevenue: 0, - fairiesFee: 0, + zundaTax: 0, mixerPoopFine: 0, truckPoopFine: 0, maleTruckFine: 0, @@ -46,7 +46,7 @@ export function newTotalStats() { poopMixer: 0, explosionDamage: 0, contractBonus: 0, - fairiesFee: 0, + zundaTax: 0, mixerPoopFine: 0, truckPoopFine: 0, maleTruckFine: 0, @@ -109,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; } @@ -129,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: 4 }, 'left'), + mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'), trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'), - truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right') + truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom') }; } @@ -139,27 +135,27 @@ export function resetLayout(game) { game.conveyorTiles.clear(); game.conveyorMeta.clear(); const initial = [ - // Egg Farm -> S1 top input. Shorter starter approach lane. - [4, 1], [4, 2], [4, 3], + // 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, 4], [2, 4], [1, 4], [0, 4], + [3, 5], [2, 5], [1, 5], [0, 5], // S1 right output -> S2 top input. - [5, 4], [6, 4], [7, 4], [8, 4], [9, 4], - [9, 5], [10, 5], [11, 5], [11, 6], + [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. - [10, 7], [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], - // S2 right output -> Truck receiver on the right grid edge. - // Restored a longer lane so S2-to-truck transport is readable and less congested. - [12, 7], [13, 7], [14, 7], [15, 7], [16, 7], [17, 7], [18, 7], [19, 7], [20, 7], [21, 7] - ]; for (const [col, row] of initial) { + [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: 4, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }, - { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' } + { 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); diff --git a/src/core/text.js b/src/core/text.js new file mode 100644 index 0000000..b7e9358 --- /dev/null +++ b/src/core/text.js @@ -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 `${equipmentName(id)}${buildToolPriceText(id)}`; +} + +export function buildToolButtonIds() { + return BUILD_TOOL_IDS; +} + +export function routeLabel(dest) { + return TEXT.routeLabels[dest] || String(dest).toUpperCase(); +} diff --git a/src/core/utils.js b/src/core/utils.js index 6bc549b..1e9747e 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -1,6 +1,10 @@ import { GRID } from './config.js'; -export function yen(value) { return `JPY ${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)); } diff --git a/src/game.js b/src/game.js index 94fd80e..e62fcf6 100644 --- a/src/game.js +++ b/src/game.js @@ -1,8 +1,9 @@ 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 } from './core/utils.js'; +import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; import { facilityConnectionIssues } from './systems/routing.js'; -import { applyCashDelta, collectFairiesFee, settleTruckRevenue } from './systems/economy.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'; @@ -27,6 +28,22 @@ 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; @@ -53,6 +70,7 @@ function startNextTurn() { uiSystem.updatePanels(); return; } + const zundaBasisCash = game.cash; activateAcceptedContract(game); game.phase = 'running'; game.turn += 1; @@ -65,6 +83,8 @@ function startNextTurn() { 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 = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; } game.buildTool = null; @@ -80,11 +100,10 @@ function startNextTurn() { function completeTurn() { if (game.phase !== 'running') return; const ship = settleTruckRevenue(game); - const contract = resolveContract(game, applyCashDelta); - const fairiesFee = collectFairiesFee(game); + const contract = resolveContract(game); clearActiveContract(game); game.totals.turnsCompleted += 1; - game.lastResult = { ...game.stats, ship, contract, fairiesFee, turn: game.turn, cash: game.cash }; + 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 = []; @@ -183,6 +202,11 @@ 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', () => 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')); @@ -192,4 +216,4 @@ window.addEventListener('keydown', event => { if (event.repeat) return; const na build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() }); chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick: chicks.activeQueuedChick }); -resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); +initializeStaticText(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/render/draw.js b/src/render/draw.js index cf85657..e71abea 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -1,7 +1,7 @@ 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 { upgradedTruckPrice } 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', @@ -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) * 12; - drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset, index - (count - 1) / 2); - }); - } -} -function drawColoredFlowSegment(ctx, a, b, color, offset, arrowShift = 0) { - const dx = b.x - a.x, dy = b.y - a.y; - const dist = Math.hypot(dx, dy); - if (dist < 8) return; - 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 + arrowShift * 9, 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(`UNIT ${upgradedTruckPrice(game)}円`, 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); @@ -409,15 +388,6 @@ function drawChick(ctx, chick, active, game) { ctx.save(); if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); } if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); } - if (chick.stoppedTimer > 0) { - ctx.strokeStyle = THEME.warn; - ctx.lineWidth = 4; - ctx.setLineDash([5, 4]); - ctx.beginPath(); - ctx.arc(chick.x, y, chick.radius + 10, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - } if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius); else { const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale; @@ -429,15 +399,6 @@ function drawChick(ctx, chick, active, game) { ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill(); } } - if (chick.stoppedTimer > 0) { - ctx.font = '900 10px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.lineWidth = 4; - ctx.strokeStyle = THEME.white; - ctx.fillStyle = THEME.warn; - ctx.strokeText('STOPPED', chick.x, y - chick.radius - 13); - ctx.fillText('STOPPED', chick.x, y - chick.radius - 13); - } ctx.restore(); } function nearestRatio(game, chick) { diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 4f8beb1..6274660 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,9 +1,10 @@ -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 } from '../core/entities.js'; import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; -import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner } from './routing.js'; -import { incomeMultiplier, mixerPoopPenalty, spendCash, refundCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice } from './economy.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'; @@ -44,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) { @@ -67,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'); @@ -91,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 = []; @@ -195,21 +199,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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, 300, 720, 1600][obj.level] || null; - if (obj.type === 'scanner') return null; - if (obj.type === 'facility' && ['mixer', 'truck'].includes(obj.id)) { - if ((obj.level || 1) <= 1) return 300; - return Math.ceil(900 * Math.pow(1.65, (obj.level || 1) - 2)); - } - return null; + return upgradeCostFor(obj); } function upgradeSelected() { const obj = selectedObject(); const cost = selectedUpgradeCost(obj); - if (!cost) return fail('No upgrade available'); - 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; @@ -245,15 +242,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } function showAutoScannerMenu(scanner) { - ui.modalTitle.textContent = 'Auto Scanner Configuration'; - ui.modalBody.innerHTML = `

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

`; + ui.modalTitle.textContent = 'Auto Scanner'; + ui.modalBody.innerHTML = `

Standard auto scanner.

`; 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.remove('equipment-popover'); - ui.modal.classList.add('visible'); + ui.modalActions.append(r0, r1); + positionEquipmentPopover(scanner); + ui.modal.classList.add('visible', 'equipment-popover'); } function switchScannerRole() { @@ -277,8 +273,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const p = selectedPopoverWorldPoint(obj); const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18; const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12; - const maxX = Math.max(12, window.innerWidth - 344); - const maxY = Math.max(12, window.innerHeight - 260); + 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`); } @@ -290,14 +286,20 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel ui.modalTitle.textContent = selectedTitle(obj); ui.modalBody.innerHTML = `
${lines.map(x => `

${x}

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

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

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

Income/Fine = ceil(base × 1.05^upgrades)

' : ''}`; ui.modalActions.innerHTML = ''; const cost = selectedUpgradeCost(obj); - if (cost) ui.modalActions.appendChild(modalButton(`Upgrade ${yen(cost)}`, () => { upgradeSelected(); hideModal(); }, 'primary-button')); + 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 refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession; - ui.modalActions.appendChild(modalButton(`Remove${refundable ? ' / Refund' : ''}`, () => { removeSelected(); hideModal(); }, 'facility-action danger')); - ui.modalActions.appendChild(modalButton('Close', hideModal)); + 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'); } @@ -319,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; } @@ -328,26 +330,29 @@ 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(`Queue: ${obj.queue.length}`); if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`); - if (obj.kind === 'manual') lines.push('Upgrade: removed'); + } else if (obj.type === 'conveyor') { const meta = game.conveyorMeta.get(obj.id); const comp = game.componentLookup?.get(obj.id); 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(`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)}`); diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index b236d04..b732ac2 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -3,7 +3,7 @@ import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; -import { maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, applyCashDelta } from './economy.js'; +import { 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'; @@ -45,7 +45,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck if (game.shutdownTimeLeft > 0) game.shutdownTimeLeft = Math.max(0, game.shutdownTimeLeft - dt); for (const farm of game.eggFarms) farm.shutterProgress = Math.min(1, (farm.shutterProgress || 0) + dt * 2.8); } - if (game.mixerHalfTimer > 0) game.mixerHalfTimer = Math.max(0, game.mixerHalfTimer - dt); if (producing && game.timeLeft > 0) { for (const farm of game.eggFarms) { farm.nextSpawn -= dt; @@ -240,20 +239,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const { x, y } = chick; countMixer(game); if (chick.sex === 'poop') { - const penalty = mixerPoopPenalty(game); + const penalty = applyMixerPoopFine(game); countPoopDestination(game, 'mixer'); countMistake(game); - game.stats.mixerPoopFine += penalty; - game.totals.mixerPoopFine += penalty; - applyCashDelta(game, -penalty); sludgeEffect(game, x, y); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); onGameOverCheck(); return; } - const amount = upgradedMixerPrice(game); - game.stats.mixerRevenue += amount; - applyCashDelta(game, amount); + const amount = applyMixerIncome(game); if (chick.sex === 'male') countCorrect(game); else countMistake(game); meatEffect(game, x, y); @@ -271,13 +265,10 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const target = truckTarget(game); const isTarget = isTargetTruckCargo(game, chick.sex); if (chick.sex === 'poop') { - const penalty = truckPoopPenalty(game); + const penalty = applyTruckPoopFine(game); countPoopDestination(game, 'truck'); if (isTarget) countCorrect(game); else countMistake(game); - game.stats.truckPoopFine += penalty; - game.totals.truckPoopFine += penalty; - applyCashDelta(game, -penalty); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); onGameOverCheck(); return; @@ -298,10 +289,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck return; } if (shouldFineMaleTruck(game)) { - const penalty = maleTruckPenalty(game); - game.stats.maleTruckFine += penalty; - game.totals.maleTruckFine += penalty; - applyCashDelta(game, -penalty); + const penalty = applyWrongTruckFine(game); countMistake(game); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); onGameOverCheck(); diff --git a/src/systems/contracts.js b/src/systems/contracts.js index 6006f14..570eb4f 100644 --- a/src/systems/contracts.js +++ b/src/systems/contracts.js @@ -1,5 +1,6 @@ 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 = [ { @@ -112,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; diff --git a/src/systems/economy.js b/src/systems/economy.js index c5c66ac..550cf1b 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -1,37 +1,85 @@ -import { FAIRIES_FEE_PER_DAY, INCOME_UPGRADE_RATE, MIXER_PRICE, POOP_FINE, TRUCK_PRICE } from '../core/config.js'; +import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js'; import { factoryValue, targetTruckCount, truckTarget } from './contracts.js'; +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 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 Math.pow(INCOME_UPGRADE_RATE, facilityUpgradeCount(game, id)); + return INCOME_FACILITY_IDS.includes(id) ? Math.pow(ECONOMY.incomeUpgradeRate, facilityUpgradeCount(game, id)) : 1; } export function upgradedMixerPrice(game) { - return Math.ceil(MIXER_PRICE * incomeMultiplier(game, 'mixer')); + return Math.ceil(ECONOMY.income.mixer * incomeMultiplier(game, 'mixer')); } export function upgradedTruckPrice(game) { - return Math.ceil(TRUCK_PRICE * incomeMultiplier(game, 'truck')); + return Math.ceil(ECONOMY.income.truck * incomeMultiplier(game, 'truck')); } export function mixerPoopPenalty(game) { - return Math.ceil(POOP_FINE * incomeMultiplier(game, 'mixer')); + return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer')); } export function truckPoopPenalty(game) { - return Math.ceil(POOP_FINE * incomeMultiplier(game, 'truck')); + return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck')); } export function maleTruckPenalty(game) { - const baseFine = 30 * (game.turn / 2); + const baseFine = ECONOMY.maleTruckFinePerHalfDay * (game.turn / 2); return Math.ceil(baseFine * incomeMultiplier(game, 'truck')); } -export function dailyFairiesFee(game) { - return Math.ceil(Math.max(1, game.turn) * FAIRIES_FEE_PER_DAY); +export function 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) { @@ -47,12 +95,20 @@ export function applyCashDelta(game, delta) { game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); } +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) { - game.cash -= amount; - game.stats.profit -= amount; - game.totals.profit -= amount; - game.stats.penalty += amount; - game.totals.penalty += amount; + applyPenalty(game, amount); } export function refundCash(game, amount) { @@ -63,6 +119,45 @@ export function refundCash(game, 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); @@ -71,16 +166,17 @@ export function settleTruckRevenue(game) { const adjusted = positivePayout(game, base); game.stats.pendingTruckRevenue = adjusted; game.stats.truckRevenue = adjusted; - if (adjusted > 0) applyCashDelta(game, adjusted); + if (adjusted > 0) applyRevenue(game, adjusted); return { base, adjusted, target, targetCount, unitPrice }; } -export function collectFairiesFee(game) { - const fee = dailyFairiesFee(game); - game.stats.fairiesFee += fee; - game.totals.fairiesFee += fee; - applyCashDelta(game, -fee); - return fee; +export function 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) { @@ -101,4 +197,5 @@ export function finalScore(game) { return { score, factoryValue: value, base, dailyEarned, dayPenalty, correctBonus, explosionPenalty, days }; } -export { MIXER_PRICE, TRUCK_PRICE }; +export const MIXER_PRICE = ECONOMY.income.mixer; +export const TRUCK_PRICE = ECONOMY.income.truck; diff --git a/src/systems/effects.js b/src/systems/effects.js index 22c0c06..db38f9e 100644 --- a/src/systems/effects.js +++ b/src/systems/effects.js @@ -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.ceil(box.price / 30)); - 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); diff --git a/src/systems/routing.js b/src/systems/routing.js index 43f115d..0431d6e 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -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,89 +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))); -} -function conveyorCellsAdjacentTo(game, point, blocked = []) { - if (!point) return []; - const cells = []; - for (const d of DIRS) { - const p = { col: point.col + d.dc, row: point.row + d.dr }; - if (!inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) continue; - if (blocked.some(b => b && sameCell(p, b))) continue; - cells.push(p); - } - return cells; + return adjacentCells({ col, row }).filter(p => game.conveyorTiles.has(key(p.col, p.row))); } -export function scannerInputCells(game, scanner) { - const input = scannerConnector(scanner, 'inputA'); - if (!input || !inGrid(input.col, input.row)) return []; - const exactKey = key(input.col, input.row); - if (game.conveyorTiles.has(exactKey)) return [{ ...input }]; - // Tolerance pass: after manual drag edits, accept belts immediately touching - // the intended input port. This prevents visually connected top inputs from - // failing only because the exact port cell was moved out from under the belt. - return conveyorCellsAdjacentTo(game, input, [scannerConnector(scanner, 'left'), scannerConnector(scanner, 'right')]); -} - -export function getInputScanner(game, col, row) { - return game.scanners.find(scanner => 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 => { - 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 scannerInputCells(game, scanner); -} - -function outputStartCells(game, scanner, side) { - const connector = scannerConnector(scanner, side); - if (!connector || !inGrid(connector.col, connector.row)) return []; - const exactKey = key(connector.col, connector.row); - if (game.conveyorTiles.has(exactKey)) return [{ ...connector, viaTolerance: false }]; - const outward = side === 'left' ? { dc: -1, dr: 0 } : { dc: 1, dr: 0 }; - const preferred = { col: connector.col + outward.dc, row: connector.row + outward.dr }; - const candidates = []; - if (inGrid(preferred.col, preferred.row) && game.conveyorTiles.has(key(preferred.col, preferred.row))) candidates.push({ ...preferred, viaTolerance: true }); - for (const p of conveyorCellsAdjacentTo(game, connector, [scannerConnector(scanner, 'inputA')])) { - if (!candidates.some(c => sameCell(c, p))) candidates.push({ ...p, viaTolerance: true }); - } - return candidates; -} - -function routeWithConnector(fromPoint, connector, cells, extraEnd = null) { - const points = [{ x: fromPoint.x, y: fromPoint.y }]; - const connectorPoint = cellCenter(connector.col, connector.row); - if (distance(points[points.length - 1], connectorPoint) > 1) points.push(connectorPoint); - for (const p of routePoints(cells, extraEnd)) { - if (distance(points[points.length - 1], p) > 1) points.push(p); - } - return points; -} -export function routePoints(cells, extraEnd = null) { - const pts = cells.map(p => cellCenter(p.col, p.row)); - if (extraEnd) pts.push(extraEnd); - return pts; -} function dirBetween(a, b) { const dc = b.col - a.col, dr = b.row - a.row; return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null; } + function isCross(game, col, row) { const ns = getConveyorNeighbors(game, col, row); const names = new Set(ns.map(n => n.dir.name)); return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down'); } -// Routing rule: a four-way cross conveyor is an overpass/crossing. Chicks never turn there. -// The BFS state includes the incoming direction so every route that enters a cross must leave straight. +// 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 []; @@ -144,171 +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) { - const rules = scannerRules(scanner); - return { left: rules.left.destination, right: rules.right.destination }; -} -export function scannerRules(scanner) { - if (scanner.role === 0) { - return { - left: { match: 'male', destination: 'mixer' }, - right: { match: 'default', destination: 'scanner-role-1' } - }; - } - return { - left: { match: 'poop', destination: 'trash' }, - right: { match: 'default', destination: 'truck' } - }; -} -export function autoSideFor(scanner, chick) { - const rules = scannerRules(scanner); - if (rules.left.match === chick.sex) return 'left'; - if (rules.right.match === chick.sex) return 'right'; - if (rules.left.match === 'default') return 'left'; - return 'right'; -} -export function destinationLabel(dest) { - return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase(); -} -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); - 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') { - 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)) }; - } - 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)) }; -} - -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(); @@ -346,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; +} diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index 7889502..d42d113 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -1,8 +1,9 @@ -import { BUILD_COSTS, GRID } from '../core/config.js'; +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 } from './routing.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) { @@ -114,7 +115,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme 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 }; + 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 } }; @@ -207,7 +208,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme 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 }); + game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null }); o.currentKey = newK; } for (const origin of game.groupDrag.origins) { @@ -226,11 +227,13 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme 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(); } diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index f13904d..140ffbf 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -1,7 +1,8 @@ -import { CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FAIRIES_FEE_PER_DAY, 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 { finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice } from './economy.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 }) { @@ -18,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 | Click equipment for upgrade menu` - : 'Sorting: use scanner keys. Build after clearing the day.'; + ? TEXT.status.build(game.buildTool) + : TEXT.status.sorting; } function updateHistoryButtons() { @@ -36,14 +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 feeDay = game.phase === 'build' ? game.turn + 1 : game.turn; - const fairiesFee = Math.ceil(Math.max(1, feeDay) * FAIRIES_FEE_PER_DAY); + const tax = zundaTaxInfo(game.cash); ui.turnSummary.innerHTML = [ `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, - `Fairies社 fee: Day ${feeDay} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(fairiesFee)}`, + `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}%`, `Belt: ${displaySpeed()}px/s fixed | Farms connected: ${connected}/${game.eggFarms.length}`, - issues.length ? `Blocked: ${issues[0]}` : `All receiver ports connected.` + issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); } @@ -51,7 +69,7 @@ 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) { @@ -80,7 +98,7 @@ 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 open upgrade / remove choices.'; + ui.facilityPanel.innerHTML = TEXT.status.equipmentPanelEmpty; return; } ui.facilityPanel.className = 'facility-card'; @@ -90,6 +108,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } } 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'); @@ -115,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); } @@ -142,17 +163,17 @@ 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.modalTitle.textContent = TEXT.versionTitle; ui.modalBody.innerHTML = `
MMale->MixerS1: A
FFemale->TruckS1: D, then S2: right
PPoop->ShredderS1: D, then S2: left

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

`; ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(button('Start Game', startGame, 'primary-button')); + ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button')); ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } @@ -178,7 +199,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
Mixer income${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}
Poop fineMixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}
Wrong truck fine${yen(r.maleTruckFine)}
-
Fairies社 feeDay ${r.turn} × ${yen(FAIRIES_FEE_PER_DAY)} = -${yen(r.fairiesFee)}
+
ZUNDA TAXOpening tax paid on Next Day: -${yen(r.zundaTax || 0)}
Explosion penalty${yen(r.explosionDamage)}
Daily earned formula(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}
Net profit formula${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}
@@ -186,7 +207,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ${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'); } @@ -195,12 +216,12 @@ 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.modalTitle.textContent = TEXT.phases.gameover; ui.modalBody.innerHTML = `

Your cash went negative.

Final score formula
Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}
Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}
-
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
Fairies社 Fees${yen(game.totals.fairiesFee)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; +
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
ZUNDA TAX${yen(game.totals.zundaTax)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(button('Restart', startGame, 'primary-button')); + ui.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button')); ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } diff --git a/styles.css b/styles.css index 2025574..381184e 100644 --- a/styles.css +++ b/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,7 +115,7 @@ 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); @@ -149,10 +149,10 @@ h1, h2, p { margin: 0; } 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)); + width: min(250px, calc(100vw - 24px)); + max-height: min(280px, calc(100vh - 24px)); overflow: auto; - padding: 10px; + padding: 8px; border-width: 3px; pointer-events: auto; box-shadow: 6px 6px 0 rgba(16,32,21,.18); @@ -167,16 +167,16 @@ h1, h2, p { margin: 0; } justify-content: flex-start; } .modal.equipment-popover .modal-actions button { - padding: 7px 9px; - font-size: 10px; + padding: 5px 7px; + font-size: 9px; } .equipment-menu-lines.compact { padding: 7px; margin: 6px 0; - font-size: 10px; + font-size: 9px; } .formula-box.compact { padding: 7px; margin: 6px 0; - font-size: 10px; + font-size: 9px; }