diff --git a/index.html b/index.html index 362d1cd..efb67fa 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v27.6 + Chick Sorter v27.0 @@ -17,7 +17,6 @@
NET¥0
DAY1
PHASETitle
-
COMBO0
@@ -36,8 +35,8 @@
-

CHICK SORTER v27.6

-

Build, expand, connect at least one valid EGG route, then start the next day.

+

CHICK SORTER v27.0

+

Build, drag, connect at least one valid EGG route, then start the next day.

@@ -55,7 +54,6 @@ -
Build tools unlock after each day.
@@ -72,8 +70,6 @@
-
-
@@ -81,19 +77,6 @@
- -
- DBG -
- - - - - - -
-
-
@@ -105,6 +88,6 @@ - + diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..90ddd52 --- /dev/null +++ b/src/README.md @@ -0,0 +1,27 @@ +# Chick Sorter v11.0 Routing / Compact Upgrades + +Open `index.html` in a browser. + +## 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 diff --git a/src/core/balance.js b/src/core/balance.js index 6f8acf8..647d0a1 100644 --- a/src/core/balance.js +++ b/src/core/balance.js @@ -1,7 +1,7 @@ // Central balance sheet for tuning gameplay. // Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps. export const BALANCE = { - version: 'v27.6 direct conveyor chick movement', + version: 'v27.0 cleanup build', time: { daySeconds: 60, farmShutdownGraceSeconds: 10 @@ -10,18 +10,11 @@ export const BALANCE = { starting: 250 }, grid: { - x: 170, - y: 78, - cols: 20, - rows: 10, - cell: 46, - expansion: { - colsPerPurchase: 10, - rowsPerPurchase: 10, - costBase: 1000, - costMultiplier: 4 / 3, - truncateUnit: 100 - } + x: 190, + y: 142, + cols: 22, + rows: 13, + cell: 46 }, map: { blockedRatio: 0.075 @@ -69,232 +62,12 @@ export const BALANCE = { bearingSpeedMultiplier: 1.075 }, cards: { - definitions: [ - { - id: 'upgradeEgg', - title: 'EGG Improvement', - rarity: 'common', - type: 'equipmentUpgrade', - target: 'eggFarm', - description: 'Choose one EGG FARM on the map and raise it by 1 level.', - tags: ['EGG', 'ACTIVE'] - }, - { - id: 'upgradeAutoScanner', - title: 'AUTO SCANNER Improvement', - rarity: 'common', - type: 'equipmentUpgrade', - target: 'autoScanner', - description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.', - tags: ['SCANNER', 'ACTIVE'] - }, - { - id: 'upgradeMixer', - title: 'MIXER Improvement', - rarity: 'common', - type: 'equipmentUpgrade', - target: 'mixer', - description: 'Choose MIXER on the map and raise it by 1 level.', - tags: ['ECONOMY', 'ACTIVE'] - }, - { - id: 'upgradeTruck', - title: 'TRUCK Improvement', - rarity: 'common', - type: 'equipmentUpgrade', - target: 'truck', - description: 'Choose TRUCK on the map and raise it by 1 level.', - tags: ['ECONOMY', 'ACTIVE'] - }, - { - id: 'upgradeTrash', - title: 'SHREDDER Improvement', - rarity: 'common', - type: 'equipmentUpgrade', - target: 'trash', - description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.', - tags: ['POOP', 'ACTIVE'] - }, - { - id: 'crowdedFarming', - title: 'Crowded Farming Contract', - rarity: 'common', - type: 'instant', - description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.', - tags: ['EGG', 'RISK', 'PASSIVE'] - }, - { - id: 'hatchingFeed', - title: 'Hatching Feed', - rarity: 'common', - type: 'instant', - description: 'EGG production interval -4%. Poop rate +3%. No card limit.', - tags: ['EGG', 'POOP', 'PASSIVE'] - }, - { - id: 'safetyCover', - title: 'Safety Cover', - rarity: 'common', - type: 'instant', - description: 'Explosion damage -5%. No card limit.', - tags: ['RISK', 'PASSIVE'] - }, - { - id: 'preventiveMaintenance', - title: 'Preventive Maintenance Manual', - rarity: 'common', - type: 'instant', - description: 'All equipment deterioration speed -5%. No card limit.', - tags: ['MAINTENANCE', 'PASSIVE'] - }, - { - id: 'dudFilter', - title: 'DUD Filter', - rarity: 'common', - type: 'instant', - description: 'DUD chance -5 percentage points. Stacks until DUD chance reaches 0%.', - tags: ['CARD', 'PASSIVE'] - }, - { - id: 'durabilityCoating', - title: 'Durability Coating', - rarity: 'common', - type: 'instant', - description: 'All equipment maximum durability +3%. No card limit.', - tags: ['MAINTENANCE', 'PASSIVE'] - }, - { - id: 'bearing', - title: 'High-Quality Bearing', - rarity: 'common', - type: 'instant', - description: 'Conveyor speed +7.5%.', - tags: ['CONVEYOR', 'PASSIVE'] - }, - { - id: 'extraEggOutlet', - title: 'Extra Egg Outlet', - rarity: 'rare', - type: 'equipmentUpgrade', - target: 'eggOutlet', - description: 'Choose one EGG FARM and add +1 output port to that EGG. Max +3 per EGG.', - tags: ['EGG', 'RARE', 'ACTIVE'] - }, - { - id: 'recyclingSubsidy', - title: 'Recycling Subsidy', - rarity: 'rare', - type: 'instant', - description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.', - tags: ['ECONOMY', 'RARE', 'PASSIVE'] - }, - { - id: 'dynamite', - title: 'Dynamite', - rarity: 'rare', - type: 'cellAction', - target: 'blockedCell', - description: 'Remove any 3 blocked cells with a burst effect.', - tags: ['RISK', 'RARE', 'ONE-SHOT'] - }, - { - id: 'usedMachine', - title: 'Used Machines', - rarity: 'rare', - type: 'instant', - description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.', - tags: ['ECONOMY', 'RISK', 'RARE'] - }, - { - id: 'newMachine', - title: 'New Machines', - rarity: 'common', - type: 'instant', - description: 'Cancel Used Machines and restore normal prices, refunds, and durability.', - tags: ['ECONOMY', 'ONE-SHOT'] - }, - { - id: 'flattery', - title: 'Flattery', - rarity: 'rare', - type: 'instant', - description: 'Next Fairies tribute -50%.', - tags: ['ECONOMY', 'RARE', 'ONE-SHOT'] - }, - { - id: 'loan', - title: 'Loan', - rarity: 'rare', - type: 'instant', - description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.', - tags: ['ECONOMY', 'RISK', 'RARE'] - }, - { - id: 'extraCards', - title: 'Extra Cards', - rarity: 'rare', - type: 'instant', - description: 'Draw 2 cards now and choose both.', - tags: ['RARE', 'ACTIVE'] - }, - { - id: 'legalWork', - title: 'Legal Work', - rarity: 'rare', - type: 'instant', - description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.', - tags: ['ECONOMY', 'RARE', 'PASSIVE'] - }, - { - id: 'chemicalWeaponSubsidy', - title: 'Chemical Weapons Subsidy', - rarity: 'ultraRare', - type: 'instant', - description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.', - tags: ['POOP', 'ULTRA RARE', 'PASSIVE'] - }, - { - id: 'laborExploitation', - title: 'Motivational Exploitation', - rarity: 'ultraRare', - type: 'instant', - description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.', - tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'] - }, - { - id: 'rescueLoan', - title: 'Emergency Loan', - rarity: 'ultraRare', - type: 'instant', - description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.', - tags: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT'] - } - ], - defaultEffects: { - bearing: 0, - legalWork: 0, - fairiesFlatteryNext: 0, - crowdedFarming: 0, - extraEggOutlet: 0, - hatchingFeed: 0, - safetyCover: 0, - recyclingSubsidy: 0, - chemicalWeaponSubsidy: 0, - preventiveMaintenance: 0, - dudFilter: 0, - durabilityCoating: 0, - laborExploitation: 0, - usedMachineActive: false, - rescueLoanCharges: 0, - loans: [] - }, commonWeight: 8, rareWeight: 2, ultraRareWeight: 0.45, baseDraftSize: 3, maxDudsPerDraft: 2, dudChancePerCard: 0.30, - dudChanceReductionPerFilter: 0.05, extraCardsAddedPicks: 2, rerollCost: { dayDivisor: 2, @@ -321,8 +94,8 @@ export const BALANCE = { }, repairman: { dailyCost: 100, - secondsPerOnePercent: 0.5, - walkSpeed: 90 + secondsPerOnePercent: 1 / 3, + walkSpeed: 140 } }, facilities: { diff --git a/src/core/config.js b/src/core/config.js index 9997553..23bcf64 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -18,8 +18,6 @@ export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed; export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier; export const CARD_BALANCE = BALANCE.cards; -export const CARD_DEFS = BALANCE.cards.definitions; -export const CARD_DEFAULT_EFFECTS = BALANCE.cards.defaultEffects; export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges; export const GRID = BALANCE.grid; export const FACILITY_DEFS = BALANCE.facilities; @@ -28,7 +26,14 @@ export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScan 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 f769b41..7e9db82 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -2,27 +2,10 @@ import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js'; import { nextSpawnDelay } from './state.js'; import { currentPoopRate } from '../systems/contracts.js'; -const DEFAULT_MANUAL_KEYS = [ - [{ key: 'a', code: 'KeyA', label: 'A' }, { key: 'd', code: 'KeyD', label: 'D' }], - [{ key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, { key: 'arrowright', code: 'ArrowRight', label: 'Right' }], - [{ key: 'j', code: 'KeyJ', label: 'J' }, { key: 'l', code: 'KeyL', label: 'L' }], - [{ key: 'f', code: 'KeyF', label: 'F' }, { key: 'h', code: 'KeyH', label: 'H' }], - [{ key: 'q', code: 'KeyQ', label: 'Q' }, { key: 'e', code: 'KeyE', label: 'E' }], - [{ key: 'z', code: 'KeyZ', label: 'Z' }, { key: 'x', code: 'KeyX', label: 'X' }], - [{ key: 'u', code: 'KeyU', label: 'U' }, { key: 'o', code: 'KeyO', label: 'O' }], - [{ key: 'n', code: 'KeyN', label: 'N' }, { key: 'm', code: 'KeyM', label: 'M' }] -]; - -function manualKeysForSlot(slot) { - const pair = DEFAULT_MANUAL_KEYS[slot] || DEFAULT_MANUAL_KEYS[0]; - return { left: { ...pair[0] }, right: { ...pair[1] } }; -} - export function createEggFarm(game, col, row) { const farm = { type: 'eggFarm', id: game.nextId++, col, row, level: 1, nextSpawn: 2, lastInterval: 2, - extraEggOutlet: 0, price: BUILD_COSTS.eggFarm, builtSession: game.buildSession }; @@ -42,8 +25,7 @@ export function createScanner(game, col, row, kind) { queue: [], cooldown: 0, price: cost, builtSession: game.buildSession, - autoMode: 'standard', - keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null + autoMode: 'standard' }; } @@ -98,17 +80,15 @@ export function rollChickSex(game) { export function createChick(game, routeData) { const sex = rollChickSex(game); - const route = (routeData.route || []).map(p => ({ x: p.x, y: p.y })); - const start = route[0]; + const start = routeData.route[0]; return { id: game.nextId++, sex, x: start.x, y: start.y, - route, - targetIndex: route.length > 1 ? 1 : route.length, - stage: routeData.stage || 'belt', - scannerId: routeData.scannerId ?? null, + route: routeData.route, + targetIndex: 1, + stage: 'input', + scannerId: routeData.scannerId, nextScannerId: null, - pendingScannerId: null, radius: sex === 'poop' ? 15 : 17, bob: Math.random() * Math.PI * 2, queueIndex: -1, diff --git a/src/core/gridExpansion.js b/src/core/gridExpansion.js deleted file mode 100644 index 83e040d..0000000 --- a/src/core/gridExpansion.js +++ /dev/null @@ -1,160 +0,0 @@ -import { BALANCE } from './balance.js'; -import { GRID } from './config.js'; -import { key, parseKey } from './utils.js'; - -export const GRID_CHUNK_SIZE = 10; -const INITIAL_CHUNKS_X = 2; -const INITIAL_CHUNKS_Y = 1; - -function chunkKey(chunkX, chunkY) { return `${chunkX},${chunkY}`; } -function parseChunkKey(value) { - const [chunkX, chunkY] = String(value).split(',').map(Number); - return { chunkX, chunkY }; -} - -export function expansionCost(game) { - const cfg = BALANCE.grid.expansion || {}; - const purchaseCount = Math.max(1, (game.gridExpansionPurchases || 0) + 1); - const raw = (cfg.costBase || 1000) * purchaseCount * (cfg.costMultiplier || (4 / 3)); - const unit = Math.max(1, cfg.truncateUnit || 100); - return Math.floor(raw / unit) * unit; -} - -export function resetOwnedCells(game) { - game.ownedCells = new Set(); - game.ownedChunks = new Set(); - game.gridChunkOriginY = 0; - game.gridExpansionPurchases = 0; - game.gridExpansionPurchasesByDirection = { right: 0, up: 0 }; - for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) { - for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY)); - } - for (let row = 0; row < BALANCE.grid.rows; row += 1) { - for (let col = 0; col < BALANCE.grid.cols; col += 1) game.ownedCells.add(key(col, row)); - } -} - -export function ensureOwnedChunks(game) { - if (game.ownedChunks?.size) return game.ownedChunks; - game.ownedChunks = new Set(); - if (game.ownedCells?.size) { - const originY = game.gridChunkOriginY || 0; - for (const k of game.ownedCells) { - const p = parseKey(k); - const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE); - const chunkY = Math.floor(p.row / GRID_CHUNK_SIZE) + originY; - game.ownedChunks.add(chunkKey(chunkX, chunkY)); - } - } else { - for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) { - for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY)); - } - } - return game.ownedChunks; -} - -export function isOwnedCell(game, col, row) { - if (!game?.ownedCells || game.ownedCells.size === 0) return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; - return game.ownedCells.has(key(col, row)); -} - -export function countOwnedCells(game) { - return game?.ownedCells?.size || (GRID.cols * GRID.rows); -} - -export function lotFromChunk(game, chunkX, chunkY, direction = 'up') { - const originY = game.gridChunkOriginY || 0; - return { - direction, - key: chunkKey(chunkX, chunkY), - chunkX, - chunkY, - colStart: chunkX * GRID_CHUNK_SIZE, - rowStart: (chunkY - originY) * GRID_CHUNK_SIZE, - cols: GRID_CHUNK_SIZE, - rows: GRID_CHUNK_SIZE - }; -} - -export function expansionLots(game) { - const owned = ensureOwnedChunks(game); - const candidates = new Map(); - for (const k of owned) { - const { chunkX, chunkY } = parseChunkKey(k); - const right = { chunkX: chunkX + 1, chunkY, direction: 'right' }; - const up = { chunkX, chunkY: chunkY - 1, direction: 'up' }; - for (const candidate of [right, up]) { - if (candidate.chunkX < 0 || candidate.chunkY > 0) continue; - const ck = chunkKey(candidate.chunkX, candidate.chunkY); - if (owned.has(ck) || candidates.has(ck)) continue; - candidates.set(ck, lotFromChunk(game, candidate.chunkX, candidate.chunkY, candidate.direction)); - } - } - return [...candidates.values()].sort((a, b) => { - const dir = (a.direction === b.direction) ? 0 : (a.direction === 'right' ? -1 : 1); - if (dir) return dir; - if (a.chunkY !== b.chunkY) return a.chunkY - b.chunkY; - return a.chunkX - b.chunkX; - }); -} - -export function expansionLot(game, direction = 'right') { - const lots = expansionLots(game); - return lots.find(lot => lot.direction === direction) || lots[0] || null; -} - -export function lotBounds(lot) { - return { - x: GRID.x + lot.colStart * GRID.cell, - y: GRID.y + lot.rowStart * GRID.cell, - w: lot.cols * GRID.cell, - h: lot.rows * GRID.cell - }; -} - -export function expansionButtonBounds(lot) { - const b = lotBounds(lot); - return { - x: b.x + b.w / 2 - 116, - y: b.y + b.h / 2 - 36, - w: 232, - h: 72, - cx: b.x + b.w / 2, - cy: b.y + b.h / 2 - }; -} - -export function lotContainsPoint(lot, p) { - const b = expansionButtonBounds(lot); - return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h; -} - -export function cellsInLot(lot) { - const cells = []; - for (let row = lot.rowStart; row < lot.rowStart + lot.rows; row += 1) { - for (let col = lot.colStart; col < lot.colStart + lot.cols; col += 1) cells.push({ col, row }); - } - return cells; -} - -export function shiftRowIndexedSet(set, rowDelta) { - const next = new Set(); - for (const k of set || []) { - const p = parseKey(k); - next.add(key(p.col, p.row + rowDelta)); - } - return next; -} - -export function shiftRowIndexedMap(map, rowDelta) { - const next = new Map(); - for (const [k, v] of map || []) { - const p = parseKey(k); - next.set(key(p.col, p.row + rowDelta), v); - } - return next; -} - -export function ownChunk(game, chunkX, chunkY) { - ensureOwnedChunks(game).add(chunkKey(chunkX, chunkY)); -} diff --git a/src/core/mapGen.js b/src/core/mapGen.js index 15712ad..4b8b51f 100644 --- a/src/core/mapGen.js +++ b/src/core/mapGen.js @@ -41,25 +41,3 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] } const target = Math.round(GRID.cols * GRID.rows * targetRatio); return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); } - -export function generateBlockedCellsInRect({ seed, colStart = 0, colEnd = GRID.cols, rowStart = 0, rowEnd = GRID.rows, targetRatio = 0.20, reserved = [] } = {}) { - const rng = mulberry32(hashSeed(seed || createRunSeed())); - const reservedSet = new Set(reserved.map(p => typeof p === 'string' ? p : key(p.col, p.row))); - const candidates = []; - const c0 = Math.max(0, Math.floor(colStart)); - const c1 = Math.max(c0, Math.floor(colEnd)); - const r0 = Math.max(0, Math.floor(rowStart)); - const r1 = Math.max(r0, Math.floor(rowEnd)); - for (let row = r0; row < r1; row += 1) { - for (let col = c0; col < c1; col += 1) { - const k = key(col, row); - if (!reservedSet.has(k)) candidates.push({ col, row, k }); - } - } - for (let i = candidates.length - 1; i > 0; i -= 1) { - const j = Math.floor(rng() * (i + 1)); - [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; - } - const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio); - return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); -} diff --git a/src/core/state.js b/src/core/state.js index b0c45c0..9e7e66e 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,8 +1,7 @@ import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID, EGG_SPAWN_RANGES } from './config.js'; import { BALANCE } from './balance.js'; import { createRunSeed, generateBlockedCells } from './mapGen.js'; -import { resetOwnedCells } from './gridExpansion.js'; -import { randomBetween, key, inGrid, directionNameBetweenCells } from './utils.js'; +import { randomBetween, key, inGrid } from './utils.js'; export function newTurnStats() { return { @@ -21,12 +20,6 @@ export function newTurnStats() { pendingTruckRevenue: 0, mixerRevenue: 0, truckRevenue: 0, - chickShipmentIncome: 0, - poopShipmentIncome: 0, - manualComboBonus: 0, - manualComboSuccess: 0, - manualComboFailure: 0, - repairWorkerWages: 0, zundaTax: 0, fairiesTribute: 0, loanRepayment: 0, @@ -69,10 +62,6 @@ export function newTotalStats() { loanRepayment: 0, chemicalWeaponSubsidy: 0, rescueLoan: 0, - manualComboBonus: 0, - manualComboSuccess: 0, - manualComboFailure: 0, - repairWorkerWages: 0, cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, @@ -97,12 +86,6 @@ export function createGame() { lastTimestamp: 0, nextId: 10, runSeed: createRunSeed(), - gridCols: BALANCE.grid.cols, - gridRows: BALANCE.grid.rows, - gridExpansionPurchases: 0, - gridExpansionPurchasesByDirection: { right: 0, up: 0 }, - gridChunkOriginY: 0, - ownedCells: new Set(), blockedCells: new Set(), buildTool: null, buildSession: 0, @@ -113,7 +96,7 @@ export function createGame() { selectionBox: null, groupDrag: null, pan: null, - view: { x: 0, y: 0, scale: 1 }, + view: { x: 0, y: 0 }, hover: null, chicks: [], effects: [], @@ -135,12 +118,10 @@ export function createGame() { lastExplodedComponent: new Map(), contractOffer: null, contractActive: null, - cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] }, + cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] }, cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }, cardTargetPick: null, - manualCombo: { count: 0, lastBonus: 0 }, - pendingRepairWorkerWages: 0, - repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 }, + repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 }, stats: newTurnStats(), lastResult: null, totals: newTotalStats() @@ -172,34 +153,34 @@ function facilityBodyForEntry(id, entry, side) { export function defaultFacilities() { return { - // Initial grid is 20 columns × 10 rows. Machine bodies sit outside the starter grid. - mixer: facilityBodyForEntry('mixer', { col: 0, row: 4 }, 'left'), - trash: facilityBodyForEntry('trash', { col: 12, row: 9 }, 'bottom'), - truck: facilityBodyForEntry('truck', { col: 18, row: 9 }, 'bottom') + // Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge. + mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'), + trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'), + truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom') }; } const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ - // Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated - // belt streams keep at least one blank tile between side-by-side flows. - [1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2], [8, 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. - [7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4], + [3, 5], [2, 5], [1, 5], [0, 5], // S1 right output -> S2 top input. - [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 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. - [14, 7], [13, 7], [12, 7], [12, 8], [12, 9], + [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], // S2 right output -> Truck receiver on the bottom grid edge. - [16, 7], [17, 7], [18, 7], [18, 8], [18, 9] + [12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12] ]); const INITIAL_SCANNERS = Object.freeze([ - { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 8, row: 4, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'a', code: 'KeyA', label: 'A' }, right: { key: 'd', code: 'KeyD', label: 'D' } } }, - { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 15, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, right: { key: 'arrowright', code: 'ArrowRight', label: 'Right' } } } + { 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 INITIAL_EGG_FARMS = Object.freeze([ - { type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null } + { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null } ]); function addConveyorTile(game, col, row, options = {}) { @@ -212,34 +193,11 @@ function addConveyorTile(game, col, row, options = {}) { builtSession: null, uses: 0, durability: BALANCE.maintenance.durability.conveyor, - maintenanceType: 'conveyor', - dir: options.dir || null, - outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : [] + maintenanceType: 'conveyor' }); - } else if (options.dir || options.outDirs) { - const meta = game.conveyorMeta.get(k); - if (options.dir) meta.dir = options.dir; - if (Array.isArray(options.outDirs)) meta.outDirs = [...new Set([...(meta.outDirs || []), ...options.outDirs])]; } } -function ensureConveyorMeta(game, col, row) { - const k = key(col, row); - if (!game.conveyorMeta.has(k)) addConveyorTile(game, col, row); - return game.conveyorMeta.get(k); -} - -function markInitialConveyorDirection(game, from, to) { - const dir = directionNameBetweenCells(from, to); - if (!dir) return; - const fromMeta = ensureConveyorMeta(game, from.col, from.row); - fromMeta.outDirs = Array.isArray(fromMeta.outDirs) ? fromMeta.outDirs : []; - if (!fromMeta.outDirs.includes(dir)) fromMeta.outDirs.push(dir); - fromMeta.dir = dir; - const toMeta = ensureConveyorMeta(game, to.col, to.row); - if (!toMeta.dir) toMeta.dir = dir; -} - function scannerPortCells(scanner) { return [ { col: scanner.col, row: scanner.row - 1 }, @@ -273,9 +231,7 @@ function protectedInitialCells(game) { } function installInitialConveyorBackbone(game) { - const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row })); - for (const { col, row } of cells) addConveyorTile(game, col, row); - for (let i = 0; i < cells.length - 1; i += 1) markInitialConveyorDirection(game, cells[i], cells[i + 1]); + for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row); } function scrubBlockedCellsFromInitialBackbone(game) { @@ -284,27 +240,12 @@ function scrubBlockedCellsFromInitialBackbone(game) { } export function resetLayout(game) { - GRID.x = BALANCE.grid.x; - GRID.y = BALANCE.grid.y; - GRID.cols = BALANCE.grid.cols; - GRID.rows = BALANCE.grid.rows; - game.gridCols = GRID.cols; - game.gridRows = GRID.rows; - resetOwnedCells(game); - game.gridOwnedCells = game.ownedCells.size; - if (game.repairman) { - game.repairman.x = GRID.x + GRID.cell * 0.5; - game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84; - game.repairman.target = null; - game.repairman.active = false; - game.repairman.hiredForNextDay = false; - } game.conveyorTiles.clear(); game.conveyorMeta.clear(); game.blockedCells = new Set(); installInitialConveyorBackbone(game); game.facilities = defaultFacilities(); - game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] })); + game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] })); game.eggFarms = INITIAL_EGG_FARMS.map(farm => { const nextSpawn = nextSpawnDelay(farm); return { ...farm, nextSpawn, lastInterval: nextSpawn }; diff --git a/src/core/text.js b/src/core/text.js index e387544..f854201 100644 --- a/src/core/text.js +++ b/src/core/text.js @@ -1,4 +1,4 @@ -import { AUTO_SCANNER_COOLDOWN, FACILITY_DEFS, VERSION } from './config.js'; +import { AUTO_SCANNER_COOLDOWN, BUILD_TOOL_IDS, FACILITY_DEFS, VERSION } from './config.js'; import { yen } from './utils.js'; export const TEXT = { @@ -10,7 +10,6 @@ export const TEXT = { build: 'Build', running: 'Sorting', clearing: 'Clearing', - loanDecision: 'Emergency Loan', gameover: 'Game Over' }, actions: { @@ -53,6 +52,10 @@ 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 ''; @@ -80,6 +83,10 @@ export function buildToolButtonHtml(id) { return `${equipmentName(id)}${buildToolPriceText(id)}${flavor ? `${flavor}` : ''}`; } +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 64168b3..1e9747e 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -21,18 +21,6 @@ export function pointToCell(x, y) { } export function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } export function sameCell(a, b) { return a && b && a.col === b.col && a.row === b.row; } - -export function directionNameBetweenCells(a, b) { - if (!a || !b) return null; - const dc = Math.sign((b.col ?? 0) - (a.col ?? 0)); - const dr = Math.sign((b.row ?? 0) - (a.row ?? 0)); - if (Math.abs((b.col ?? 0) - (a.col ?? 0)) + Math.abs((b.row ?? 0) - (a.row ?? 0)) !== 1) return null; - if (dc === 1 && dr === 0) return 'right'; - if (dc === -1 && dr === 0) return 'left'; - if (dc === 0 && dr === 1) return 'down'; - if (dc === 0 && dr === -1) return 'up'; - return null; -} export function lerp(a, b, t) { return a + (b - a) * t; } export function hexToRgb(hex) { const s = hex.replace('#', ''); diff --git a/src/game.js b/src/game.js index f652d66..d4a35fa 100644 --- a/src/game.js +++ b/src/game.js @@ -1,16 +1,15 @@ -import { GRID, TURN_SECONDS, THEME } from './core/config.js'; +import { TURN_SECONDS, THEME } from './core/config.js'; import { buildToolButtonHtml } from './core/text.js'; import { createGame, resetLayout, newTurnStats } from './core/state.js'; -import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js'; -import { expansionLots, lotBounds } from './core/gridExpansion.js'; +import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js'; -import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; +import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { undo, redo } from './systems/history.js'; import { drawAll } from './render/draw.js'; import { createBuildSystem } from './systems/buildSystem.js'; import { createUISystem } from './systems/uiSystem.js'; import { createChickSystem } from './systems/chickSystem.js'; -import { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } from './systems/cards.js'; +import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js'; import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js'; @@ -20,25 +19,13 @@ const ctx = canvas.getContext('2d'); const ui = { shell: document.getElementById('gameShell'), - money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'), + money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'), turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'), buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'), hoverTooltip: document.getElementById('hoverTooltip'), - manualScannerMonitor: document.getElementById('manualScannerMonitor'), hireRepairmanButton: document.getElementById('hireRepairmanButton'), - expandGridButton: document.getElementById('expandGridButton'), - debug: { - panel: document.getElementById('debugPanel'), - infiniteCash: document.getElementById('debugInfiniteCash'), - dayInput: document.getElementById('debugDayInput'), - setDay: document.getElementById('debugSetDayButton'), - cardSelect: document.getElementById('debugCardSelect'), - grantCard: document.getElementById('debugGrantCardButton'), - grantAllCards: document.getElementById('debugGrantAllCardsButton'), - readout: document.getElementById('debugReadout') - }, buttons: { s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'), nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton') @@ -61,15 +48,6 @@ function initializeStaticText() { } } -function initializeDebugPanel() { - if (!ui.debug?.panel) return; - if (ui.debug.cardSelect) { - ui.debug.cardSelect.innerHTML = CARD_DEFS - .map(card => ``) - .join(''); - } -} - const game = createGame(); let build; let uiSystem; @@ -84,60 +62,13 @@ function currentConveyorSpeed(conveyorKey = null) { function startGame() { Object.assign(game, createGame()); game.phase = 'running'; - game.view = { x: 0, y: 0, scale: 1 }; + game.view = { x: 0, y: 0 }; resetLayout(game); - resetCameraToFactoryStart(); ensureMaintenanceState(game); uiSystem.hideModal(); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); - updateDebugReadout(); -} - -function debugSetDay() { - const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1)); - game.turn = day; - if (ui.debug.dayInput) ui.debug.dayInput.value = String(day); - floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green); - uiSystem.updatePanels(); - uiSystem.updateUI(); - updateDebugReadout(`Day set to ${day}.`); -} - -function debugGrantSelectedCard() { - const id = ui.debug.cardSelect?.value; - const result = debugGrantCard(game, id); - uiSystem.updatePanels(); - uiSystem.updateUI(); - updateDebugReadout(result.reason); -} - -function debugGrantAllCards() { - const results = CARD_DEFS.map(card => debugGrantCard(game, card)); - const ok = results.filter(result => result.ok).length; - uiSystem.updatePanels(); - uiSystem.updateUI(); - updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`); -} - -function applyDebugState() { - if (ui.debug?.infiniteCash?.checked && game.cash < 999999) { - game.cash = 999999; - game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); - } -} - -function updateDebugReadout(message = '') { - if (!ui.debug?.readout) return; - ui.debug.readout.textContent = [ - message, - `D${game.turn}`, - yen(game.cash), - `Combo ${game.manualCombo?.count || 0}`, - `Rescue ${game.cardEffects?.rescueLoanCharges || 0}`, - `Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}` - ].filter(Boolean).join(' | '); } function startNextTurn() { @@ -156,8 +87,8 @@ function startNextTurn() { ensureMaintenanceState(game); commitFactoryGraphForDay(game); activateRepairmanForDay(game); - const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)); - const zunda = collectZundaTax(game, zundaBasisProfit); + const zundaBasisCash = game.cash; + const zunda = collectZundaTax(game, zundaBasisCash); const loanRepayment = collectLoanRepayments(game); game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 }; if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger); @@ -175,17 +106,8 @@ function startNextTurn() { game.floatingTexts = []; game.shake = { time: 0, strength: 0 }; game.truckCargo = []; - resetCameraToFactoryStart(); + game.view = { x: 0, y: 0 }; game.stats = newTurnStats(); - game.stats.zundaTax = 0; - game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0; - game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0; - const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages; - if (carriedStartCosts > 0) { - game.stats.penalty += carriedStartCosts; - game.stats.profit -= carriedStartCosts; - } - game.pendingRepairWorkerWages = 0; 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; @@ -217,33 +139,17 @@ function completeTurn() { const ship = settleTruckRevenue(game); const contract = resolveContract(game); const chemical = collectChemicalWeaponSubsidy(game); - if (ship.adjusted > 0) { - const truck = game.facilities.truck; - const x = truck ? truck.x + truck.w / 2 : canvas.width / 2 - game.view.x; - const y = truck ? truck.y + 26 : 96 - game.view.y; - floating(game, x, y, `${ship.target === 'poop' ? 'POOP SHIP' : 'SHIP'} +${yen(ship.adjusted)}`, THEME.green); - } - if (contract?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `CONTRACT +${yen(contract.bonus)}`, THEME.green); if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green); clearActiveContract(game); const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1); const tribute = collectFairiesTribute(game, settlementDay); game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay }; if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger); + const rescue = applyRescueLoanIfNeeded(game); + if (rescue.amount > 0) floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(rescue.amount)}`, THEME.green); game.totals.turnsCompleted += 1; - game.lastResult = { ...game.stats, ship, contract, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: tribute, turn: game.turn, cash: game.cash }; - if (game.cash < 0) { - if ((game.cardEffects?.rescueLoanCharges || 0) > 0) { - game.phase = 'loanDecision'; - uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver); - return; - } - game.phase = 'gameover'; uiSystem.showGameOver(); return; - } - enterBuildPhase(); -} - -function enterBuildPhase() { + game.lastResult = { ...game.stats, ship, contract, chemical, rescue, fairiesTribute: tribute, turn: game.turn, cash: game.cash }; + if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } game.phase = 'build'; game.truckCargo = []; game.chicks = []; @@ -268,24 +174,6 @@ function enterBuildPhase() { uiSystem.showTurnResult(game.lastResult); } -function useEmergencyLoan() { - if ((game.cardEffects?.rescueLoanCharges || 0) <= 0) return acceptGameOver(); - game.cardEffects.rescueLoanCharges -= 1; - game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500; - game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500; - const amount = applyRevenue(game, 500); - game.lastResult = { ...(game.lastResult || {}), ...game.stats, rescue: { amount: ((game.lastResult?.rescue?.amount || 0) + amount), used: ((game.lastResult?.rescue?.used || 0) + 1), remaining: game.cardEffects.rescueLoanCharges }, cash: game.cash }; - floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(amount)}`, THEME.green); - if (game.cash >= 0) enterBuildPhase(); - else if ((game.cardEffects?.rescueLoanCharges || 0) > 0) { game.phase = 'loanDecision'; uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver); } - else acceptGameOver(); -} - -function acceptGameOver() { - game.phase = 'gameover'; - uiSystem.showGameOver(); -} - function closeFarmShutters() { if (game.shutdownTimeLeft <= 0) game.shutdownTimeLeft = game.shutdownGraceSeconds || 10; for (const farm of game.eggFarms) { @@ -303,96 +191,21 @@ function update(timestamp) { if (!game.lastTimestamp) game.lastTimestamp = timestamp; const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05); game.lastTimestamp = timestamp; - applyDebugState(); if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn }); updateEffects(game, dt, canvas, build.equipmentHitBoxes); chicks.updateCongestion(); drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle }); uiSystem.updateUI(); - updateManualScannerMonitor(); - updateDebugReadout(); requestAnimationFrame(update); } // Build/edit/selection operations live in src/systems/buildSystem.js. // HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js. // Hitbox generation lives in buildSystem.js. -function viewScale() { return game.view?.scale || 1; } -function ensureView() { - if (!game.view) game.view = { x: 0, y: 0, scale: 1 }; - if (game.view.scale == null) game.view.scale = 1; -} function rawCanvasPoint(event) { const r = canvas.getBoundingClientRect(); return { x: (event.clientX - r.left) * canvas.width / r.width, y: (event.clientY - r.top) * canvas.height / r.height }; } -function canvasPoint(event) { const p = rawCanvasPoint(event); const s = viewScale(); return { x: (p.x - (game.view?.x || 0)) / s, y: (p.y - (game.view?.y || 0)) / s }; } -function worldToScreen(p) { const s = viewScale(); return { x: p.x * s + (game.view?.x || 0), y: p.y * s + (game.view?.y || 0) }; } -function cameraWorldBounds() { - const bounds = { - left: GRID.x - 90, - top: GRID.y - 130, - right: GRID.x + GRID.cols * GRID.cell + 260, - bottom: GRID.y + GRID.rows * GRID.cell + 220 - }; - if (game.phase === 'build') { - for (const lot of expansionLots(game)) { - const b = lotBounds(lot); - bounds.left = Math.min(bounds.left, b.x - 60); - bounds.top = Math.min(bounds.top, b.y - 80); - bounds.right = Math.max(bounds.right, b.x + b.w + 80); - bounds.bottom = Math.max(bounds.bottom, b.y + b.h + 80); - } - } - return bounds; -} - -function normalizedClampRange(min, max) { - if (min <= max) return [min, max]; - const mid = (min + max) / 2; - return [mid, mid]; -} - -function clampCamera() { - ensureView(); - const s = viewScale(); - const b = cameraWorldBounds(); - const [minX, maxX] = normalizedClampRange(canvas.width - 160 - b.right * s, 110 - b.left * s); - const [minY, maxY] = normalizedClampRange(canvas.height - 150 - b.bottom * s, 150 - b.top * s); - game.view.x = clamp(game.view.x, minX, maxX); - game.view.y = clamp(game.view.y, minY, maxY); -} - -function resetCameraToFactoryStart() { - ensureView(); - game.view.scale = 1; - const farm = game.eggFarms?.[0]; - const anchor = farm ? cellCenter(farm.col, farm.row) : { x: GRID.x, y: GRID.y }; - // Keep the initial EGG and the left-side Mixer fully visible. The previous - // anchor placed the EGG at 150,150, which clipped the Mixer body at the left edge. - game.view.x = 280 - anchor.x; - game.view.y = 190 - anchor.y; - clampCamera(); -} -function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; } -function updatePan(event) { - if (!game.pan) return; - const p = rawCanvasPoint(event); - ensureView(); - game.view.x = game.pan.viewX + p.x - game.pan.start.x; - game.view.y = game.pan.viewY + p.y - game.pan.start.y; - clampCamera(); -} -function zoomAt(event) { - event.preventDefault(); - ensureView(); - const raw = rawCanvasPoint(event); - const before = canvasPoint(event); - const factor = Math.exp(-event.deltaY * 0.0012); - const nextScale = clamp(viewScale() * factor, 0.55, 1.8); - game.view.scale = nextScale; - game.view.x = raw.x - before.x * nextScale; - game.view.y = raw.y - before.y * nextScale; - clampCamera(); - hideHoverTooltip(); -} +function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x - game.view.x, y: p.y - game.view.y }; } +function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; } +function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); } function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; } function objectFromHit(hit) { if (!hit) return null; @@ -413,9 +226,8 @@ function showHoverTooltip(event, hit) { if (!obj) return hideHoverTooltip(); const title = build.selectedTitle(obj); const flavor = build.flavorText ? build.flavorText(obj) : ''; - const warning = build.disconnectedWarningFor ? build.disconnectedWarningFor(obj) : ''; const lines = build.selectedInfoLines(obj).slice(0, 7); - ui.hoverTooltip.innerHTML = `${title}${warning ? `${warning}` : (flavor ? `${flavor}` : '')}${lines.map(line => `${line}`).join('')}`; + ui.hoverTooltip.innerHTML = `${title}${flavor ? `${flavor}` : ''}${lines.map(line => `${line}`).join('')}`; const x = Math.min(window.innerWidth - 330, event.clientX + 18); const y = Math.min(window.innerHeight - 220, event.clientY + 18); ui.hoverTooltip.style.left = `${Math.max(8, x)}px`; @@ -438,119 +250,9 @@ function clickSelect(event) { game.selected = sel; game.multiSelected = [sel]; uiSystem.updatePanels(); - const obj = build.selectedObject(); - if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj); } -function escapeHtml(value) { - return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); -} - -function activeQueuedForScanner(scanner) { - const id = scanner?.queue?.[0]; - return id == null ? null : game.chicks.find(chick => chick.id === id) || null; -} - -function chickDisplay(chick) { - if (!chick) return 'WAIT'; - const label = chick.sex === 'poop' ? '💩 POOP' : chick.sex === 'male' ? '♂ MALE' : '♀ FEMALE'; - return `${label}`; -} - -function miniScannerGrid(scanner, chick) { - const farmCells = new Set(game.eggFarms.map(farm => key(farm.col, farm.row))); - const scannerCells = new Map(game.scanners.map(s => [key(s.col, s.row), s])); - const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean)); - const activeSex = chick?.sex || ''; - const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : ''; - const cells = []; - for (let row = scanner.row - 2; row <= scanner.row + 2; row += 1) { - for (let col = scanner.col - 2; col <= scanner.col + 2; col += 1) { - const k = key(col, row); - const classes = ['mini-cell']; - let label = ''; - if (col === scanner.col && row === scanner.row) { - classes.push('scanner'); - if (activeSex) classes.push(activeSex); - label = activeLabel || 'S'; - } else if (row === scanner.row - 1 && col === scanner.col) { - classes.push('input'); - label = 'IN'; - } else if (farmCells.has(k)) { - classes.push('egg'); - label = 'E'; - } else if (scannerCells.has(k)) { - classes.push('scanner-other'); - label = 'S'; - } else if (facilityEntryCells.has(k)) { - classes.push('port'); - label = 'P'; - } else if (game.conveyorTiles.has(k)) { - classes.push('belt'); - label = '•'; - } else if (game.blockedCells?.has?.(k)) { - classes.push('blocked'); - label = '×'; - } - cells.push(`${label}`); - } - } - return `
${cells.join('')}
`; -} - -function scannerIsOffscreen(scanner) { - const screen = worldToScreen(cellCenter(scanner.col, scanner.row)); - const margin = 76; - return screen.x < -margin || screen.y < -margin || screen.x > canvas.width + margin || screen.y > canvas.height + margin; -} - -function updateManualScannerMonitor() { - if (!ui.manualScannerMonitor) return; - if (game.phase !== 'running') { - ui.manualScannerMonitor.classList.remove('visible'); - ui.manualScannerMonitor.innerHTML = ''; - return; - } - const scanners = game.scanners.filter(scanner => scanner.kind === 'manual' && scannerIsOffscreen(scanner)); - if (!scanners.length) { - ui.manualScannerMonitor.classList.remove('visible'); - ui.manualScannerMonitor.innerHTML = ''; - return; - } - ui.manualScannerMonitor.innerHTML = scanners.map(scanner => { - const chick = activeQueuedForScanner(scanner); - const left = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left'); - const right = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right'); - const leftDest = scanner.role === 0 ? 'MIXER' : 'WASTE'; - const rightDest = scanner.role === 0 ? 'S2' : 'TRUCK'; - const disabled = chick ? '' : ' disabled'; - return ` -
-
S${(scanner.slot ?? 0) + 1}OFFSCREEN / Q:${scanner.queue.length}
-
- IN${chickDisplay(chick)}SCANNER -
- ${miniScannerGrid(scanner, chick)} -
- - -
-
`; - }).join(''); - ui.manualScannerMonitor.classList.add('visible'); -} - -ui.manualScannerMonitor?.addEventListener('click', event => { - const button = event.target.closest('button[data-scanner-id]'); - if (!button || button.disabled) return; - const scanner = game.scanners.find(s => s.id === Number(button.dataset.scannerId)); - if (!scanner) return; - chicks.sortScanner(scanner, button.dataset.side); - updateManualScannerMonitor(); -}); - canvas.addEventListener('contextmenu', event => event.preventDefault()); -canvas.addEventListener('wheel', zoomAt, { passive: false }); canvas.addEventListener('pointerdown', event => { if (game.phase !== 'build') return; canvas.setPointerCapture(event.pointerId); @@ -558,14 +260,8 @@ canvas.addEventListener('pointerdown', event => { if (event.button === 2) { startPan(event); return; } if (event.button !== 0) return; if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; } - const expansionOffer = build.expansionOfferAtPoint?.(world); - if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; } if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; } if (game.buildTool) { - if (game.buildTool === 'conveyor') { - build.beginConveyorDrag(pointToCell(world.x, world.y)); - return; - } const hit = build.equipmentAtPoint(world); if (hit) { game.buildTool = null; clickSelect(event); return; } build.buildAtPoint(world); @@ -580,7 +276,7 @@ canvas.addEventListener('pointermove', event => { if (game.pan && (event.buttons & 2)) updatePan(event); if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event); if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event); - if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); + if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.buildAtCell(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event)); }); canvas.addEventListener('pointerup', event => { @@ -589,12 +285,11 @@ canvas.addEventListener('pointerup', event => { if (!game.groupDrag.committed) clickSelect(event); build.finishGroupDrag(); } - if (game.buildTool === 'conveyor') build.endConveyorDrag?.(); game.groupDrag = null; game.pan = null; try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ } }); canvas.addEventListener('pointerleave', hideHoverTooltip); -canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); }); +canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); }); 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; @@ -604,30 +299,8 @@ document.addEventListener('pointerdown', event => { ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right')); ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); ui.hireRepairmanButton?.addEventListener('click', hireRepairman); -ui.expandGridButton?.addEventListener('click', () => { build?.buyGridExpansion?.('right'); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); }); -ui.debug.setDay?.addEventListener('click', debugSetDay); -ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard); -ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards); -ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); -function scannerKeyMatches(scanner, side, event) { - const binding = scanner.keys?.[side]; - if (!binding) return false; - return (binding.code && event.code === binding.code) || (binding.key && event.key.toLowerCase() === binding.key); -} - -window.addEventListener('keydown', event => { - if (event.repeat) return; - const name = event.key.toLowerCase(); - if (game.phase === 'running') { - for (const scanner of game.scanners.filter(s => s.kind === 'manual')) { - if (scannerKeyMatches(scanner, 'left', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'left'); return; } - if (scannerKeyMatches(scanner, 'right', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'right'); return; } - } - } - if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } } - if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } } - if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } -}); +ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); +window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } }); @@ -635,4 +308,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() }); uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick }); -initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update); +initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..a84c5b6 --- /dev/null +++ b/src/index.html @@ -0,0 +1,106 @@ + + + + + + Chick Sorter v27.0 + + + +
+
+ + +
+
CASH¥250
+
TIME60.0s
+
NET¥0
+
DAY1
+
PHASETitle
+
+ +
+
MIXER0
+
TRUCK F0
+
WRONG0
+
POOP0
+
+ +
+
NO ACTIVE EVENT
+
TRUCK TARGET: FEMALE
+
BELT: 52 px/s
+
+ +
+
+
+

CHICK SORTER v27.0

+

Build, drag, connect at least one valid EGG route, then start the next day.

+
+ +
+ +
+
+

Add / Edit Equipment

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

Irregular One-Day Event

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

Status

+
+
+
+
+ +
+ + + + +
+ + +
+ DBG +
+ + + + + +
+
+
+
+
+
+ + + + + + diff --git a/src/render/draw.js b/src/render/draw.js index fe00cfb..a8cebaa 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -1,7 +1,6 @@ -import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; -import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js'; +import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js'; -import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js'; +import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; import { cardTargetBounds } from '../systems/cards.js'; import { wearRatio } from '../systems/maintenance.js'; @@ -43,17 +42,13 @@ export function drawAll(ctx, canvas, game, helpers) { ctx.save(); const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0; const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0; - const scale = game.view?.scale || 1; - ctx.translate((game.view?.x || 0) + sx, (game.view?.y || 0) + sy); - ctx.scale(scale, scale); + ctx.translate(game.view.x + sx, game.view.y + sy); drawGrid(ctx, game); - drawExpansionOffers(ctx, game); drawConveyors(ctx, game); drawScannerConnectors(ctx, game); drawFacilities(ctx, game); drawEggFarms(ctx, game); drawScanners(ctx, game); - drawDisconnectedWarnings(ctx, game); drawChicks(ctx, game, helpers.activeQueuedChick); drawRepairman(ctx, game); drawCardTargetOverlay(ctx, canvas, game); @@ -65,46 +60,11 @@ export function drawAll(ctx, canvas, game, helpers) { drawCanvasHints(ctx, game, canvas); } -function warningBounds(item) { - if (item.type === 'eggFarm') { - const c = cellCenter(item.ref.col, item.ref.row); - return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y }; - } - const f = item.ref; - return { x: f.x, y: f.y, w: f.w, h: f.h, cx: f.x + f.w / 2, cy: f.y + f.h / 2 }; -} - -function drawDisconnectedWarnings(ctx, game) { - if (game.phase !== 'build') return; - const warnings = disconnectedBuildWarnings(game); - for (const item of warnings) { - const b = warningBounds(item); - ctx.save(); - ctx.fillStyle = 'rgba(196,36,36,.22)'; - ctx.strokeStyle = THEME.danger; - ctx.lineWidth = 5; - ctx.setLineDash([10, 6]); - rect(ctx, b.x, b.y, b.w, b.h, true, true); - ctx.setLineDash([]); - ctx.fillStyle = THEME.danger; - ctx.strokeStyle = THEME.white; - ctx.lineWidth = 4; - ctx.font = '900 16px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.strokeText('!', b.cx, b.y + 24); - ctx.fillText('!', b.cx, b.y + 24); - ctx.font = '900 11px ui-monospace, monospace'; - ctx.strokeText('NO ROUTE', b.cx, b.y + b.h - 8); - ctx.fillText('NO ROUTE', b.cx, b.y + b.h - 8); - ctx.restore(); - } -} - function rect(ctx, x, y, w, h, fill = true, stroke = true) { ctx.beginPath(); ctx.rect(x, y, w, h); if (fill) ctx.fill(); if (stroke) ctx.stroke(); } function label(ctx, x, y, text, color = THEME.ink) { - ctx.save(); ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore(); + ctx.save(); ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore(); } function drawImageIfLoaded(ctx, image, x, y, w, h) { if (!image?.loaded) return false; @@ -121,38 +81,22 @@ function drawBackground(ctx, canvas) { for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } ctx.restore(); } -function ownedCellKeys(game) { - if (game.ownedCells?.size) return [...game.ownedCells]; - const cells = []; - for (let row = 0; row < GRID.rows; row += 1) for (let col = 0; col < GRID.cols; col += 1) cells.push(key(col, row)); - return cells; -} - function drawGrid(ctx, game) { ctx.save(); - const cells = ownedCellKeys(game); ctx.fillStyle = 'rgba(255,255,255,.46)'; ctx.strokeStyle = 'rgba(16,32,21,.055)'; ctx.lineWidth = 1; - for (const k of cells) { - const p = parseKey(k); - const c = cellCenter(p.col, p.row); - rect(ctx, c.x - GRID.cell / 2, c.y - GRID.cell / 2, GRID.cell, GRID.cell, true, true); + rect(ctx, GRID.x - 10, GRID.y - 10, GRID.cols * GRID.cell + 20, GRID.rows * GRID.cell + 20, true, true); + for (let c = 0; c <= GRID.cols; c += 1) { + const x = GRID.x + c * GRID.cell; + ctx.beginPath(); ctx.moveTo(x, GRID.y); ctx.lineTo(x, GRID.y + GRID.rows * GRID.cell); ctx.stroke(); } - ctx.strokeStyle = 'rgba(16,32,21,.16)'; - ctx.lineWidth = 3; - for (const k of cells) { - const p = parseKey(k); - const c = cellCenter(p.col, p.row); - const x = c.x - GRID.cell / 2, y = c.y - GRID.cell / 2; - if (!isOwnedCell(game, p.col - 1, p.row)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + GRID.cell); ctx.stroke(); } - if (!isOwnedCell(game, p.col + 1, p.row)) { ctx.beginPath(); ctx.moveTo(x + GRID.cell, y); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); } - if (!isOwnedCell(game, p.col, p.row - 1)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + GRID.cell, y); ctx.stroke(); } - if (!isOwnedCell(game, p.col, p.row + 1)) { ctx.beginPath(); ctx.moveTo(x, y + GRID.cell); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); } + for (let r = 0; r <= GRID.rows; r += 1) { + const y = GRID.y + r * GRID.cell; + ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke(); } for (const k of game.blockedCells || []) { const p = parseKey(k); - if (!isOwnedCell(game, p.col, p.row)) continue; const c = cellCenter(p.col, p.row); ctx.fillStyle = 'rgba(16,32,21,.30)'; ctx.strokeStyle = 'rgba(16,32,21,.60)'; @@ -162,40 +106,6 @@ function drawGrid(ctx, game) { ctx.restore(); } -function drawExpansionOffers(ctx, game) { - if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return; - const cost = expansionCost(game); - const affordable = game.cash >= cost; - for (const lot of expansionLots(game)) { - const b = lotBounds(lot); - ctx.save(); - ctx.fillStyle = affordable ? 'rgba(183,245,198,.34)' : 'rgba(255,255,255,.22)'; - ctx.strokeStyle = affordable ? THEME.green : THEME.muted; - ctx.lineWidth = 5; - ctx.setLineDash([16, 10]); - rect(ctx, b.x, b.y, b.w, b.h, true, true); - ctx.setLineDash([]); - const button = expansionButtonBounds(lot); - const cx = button.cx; - const cy = button.cy; - ctx.fillStyle = 'rgba(255,255,255,.94)'; - ctx.strokeStyle = THEME.ink; - ctx.lineWidth = 4; - rect(ctx, button.x, button.y, button.w, button.h, true, true); - ctx.fillStyle = affordable ? THEME.green : THEME.danger; - ctx.font = '900 16px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.fillText(lot.direction === 'right' ? 'BUY RIGHT LOT' : 'BUY UPPER LOT', cx, cy - 12); - ctx.fillStyle = THEME.ink; - ctx.font = '900 16px ui-monospace, monospace'; - ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15); - ctx.fillStyle = THEME.muted; - ctx.font = '900 11px ui-monospace, monospace'; - ctx.fillText('blocked tiles included', cx, cy + 30); - ctx.restore(); - } -} - function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) { const wear = wearRatio(target); if (wear < 0.50) return; @@ -290,22 +200,8 @@ function addMarkerFromRoute(game, markers, route) { if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle); } } -function explicitConveyorAngles(meta) { - if (!meta) return []; - const names = []; - if (Array.isArray(meta.outDirs)) names.push(...meta.outDirs); - if (meta.dir) names.push(meta.dir); - return [...new Set(names)] - .map(name => DIRS.find(d => d.name === name)?.angle) - .filter(angle => Number.isFinite(angle)); -} - function collectConveyorDirectionMarkers(game) { const markers = new Map(); - for (const [k, meta] of game.conveyorMeta || []) { - const angles = explicitConveyorAngles(meta); - if (angles.length) markers.set(k, angles); - } 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']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route); @@ -334,7 +230,7 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) { function drawScannerConnectors(ctx, game) { ctx.save(); ctx.lineWidth = 2; - ctx.font = '900 12px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; for (const scanner of game.scanners) { const outputs = scannerOutputs(scanner); @@ -362,18 +258,13 @@ function drawEggFarm(ctx, farm, game) { ctx.save(); if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm); - const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0); - ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; - rect(ctx, c.x + 9, c.y + 12, 30, 18, true, true); - ctx.fillStyle = THEME.ink; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`O${outlets}`, c.x + 24, c.y + 24); drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68); ctx.restore(); return; } ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true); - ctx.fillStyle = THEME.green; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4); - const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0); - ctx.font = '900 11px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level} O${outlets}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26); + ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4); + ctx.font = '900 9px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26); const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0)); if (shutter > 0) { const h = 56 * shutter; @@ -381,7 +272,7 @@ function drawEggFarm(ctx, farm, game) { rect(ctx, c.x - 28, c.y - 28, 56, h, true, false); ctx.strokeStyle = THEME.white; ctx.lineWidth = 2; for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); } - if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); } + if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); } } drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm); drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68); @@ -391,17 +282,6 @@ function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ct function drawScanner(ctx, scanner, game) { const c = scannerCenter(scanner); ctx.save(); - if (scanner.kind === 'manual' && (game.manualCombo?.count || 0) >= 10) { - const combo = game.manualCombo.count; - ctx.save(); - ctx.globalAlpha = Math.min(0.55, 0.16 + combo / 120); - ctx.strokeStyle = combo >= 30 ? THEME.warn : THEME.green; - ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10)); - ctx.shadowColor = ctx.strokeStyle; - ctx.shadowBlur = Math.min(28, 8 + combo); - rect(ctx, c.x - 60, c.y - 44, 120, 88, false, true); - ctx.restore(); - } const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual; if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 54, c.y - 38, 108, 76, scanner); @@ -413,21 +293,19 @@ function drawScanner(ctx, scanner, game) { ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true); ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink; - ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; + ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14); - ctx.font = '900 11px ui-monospace, monospace'; - const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left'); - const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right'); - ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y + 3); + ctx.font = '900 9px ui-monospace, monospace'; + ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 3); const q = scanner.queue.length; if (scanner.kind === 'manual') { drawManualKeyboardIcon(ctx, scanner, c); ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 8px ui-monospace, monospace'; ctx.fillText(`Q:${q}`, c.x, c.y + 32); } else { ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; - ctx.font = '900 12px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25); drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner); } @@ -441,8 +319,6 @@ function drawManualKeyboardIcon(ctx, scanner, c) { const labels = slot === 0 ? [{ side: 'left', text: 'A' }, { side: 'right', text: 'D' }] : [{ side: 'left', text: '←' }, { side: 'right', text: '→' }]; - labels[0].text = scanner.keys?.left?.label || labels[0].text; - labels[1].text = scanner.keys?.right?.label || labels[1].text; const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null; const baseX = c.x - 30; const baseY = c.y + 8; @@ -451,7 +327,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) { ctx.fillStyle = '#f7fff5'; ctx.strokeStyle = THEME.ink; rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true); - ctx.font = '900 19px ui-monospace, monospace'; + ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center'; for (let i = 0; i < labels.length; i += 1) { const keyDef = labels[i]; @@ -470,7 +346,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) { ctx.fillText(keyDef.text, x + 14, y + 13); } ctx.fillStyle = THEME.muted; - ctx.font = '900 8px ui-monospace, monospace'; + ctx.font = '900 6px ui-monospace, monospace'; ctx.fillText('KEYBOARD', c.x, baseY - 7); ctx.restore(); } @@ -490,12 +366,12 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') { rect(ctx, x, y, w, 32, true, true); drawItemIcon(ctx, x + 18, y + 16, type); ctx.fillStyle = THEME.ink; - ctx.font = '900 12px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillText(title, x + 34, y + 13); if (subtitle) { ctx.fillStyle = THEME.muted; - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 8px ui-monospace, monospace'; ctx.fillText(subtitle, x + 34, y + 25); } ctx.restore(); @@ -520,7 +396,7 @@ function drawFacilityReceiver(ctx, game, id) { ctx.lineWidth = 2; rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true); drawItemIcon(ctx, c.x, c.y - 3, info.type); - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 8px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.ink; ctx.fillText(info.title, c.x, c.y + 20); @@ -557,8 +433,8 @@ function drawMixer(ctx, game) { drawExternalDuct(ctx, m); if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); 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 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28); - ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48); + 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(`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(); } @@ -572,7 +448,7 @@ function drawTrash(ctx, game) { drawExternalDuct(ctx, t); if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); 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 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25); + ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25); for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); } drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder'); drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); @@ -585,7 +461,7 @@ function drawTruck(ctx, game) { drawExternalDuct(ctx, t); if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); 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 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24); + 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 ${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'); @@ -639,7 +515,7 @@ function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); } function drawTinyChick(ctx, x, y, sex, warning = false) { ctx.save(); ctx.fillStyle = sex === 'male' ? THEME.male : THEME.female; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); } + if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); } ctx.restore(); } function selectionToken(type, id) { return `${type}:${id}`; } @@ -660,7 +536,7 @@ function drawSelection(ctx, game, type, id, x, y, w, h) { function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) { if (game.phase !== 'build') return; if ((game.multiSelected || []).length > 1) { - ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore(); + ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore(); return; } // Single equipment inspection is handled by the DOM hover tooltip. @@ -675,8 +551,7 @@ function drawCardTargetOverlay(ctx, canvas, game) { // so every non-candidate object is reliably dimmed even after panning. ctx.fillStyle = 'rgba(0,0,0,.66)'; ctx.beginPath(); - const scale = game.view?.scale || 1; - ctx.rect(-(game.view?.x || 0) / scale, -(game.view?.y || 0) / scale, canvas.width / scale, canvas.height / scale); + ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height); for (const item of items) { const b = item.bounds; const pad = 8; @@ -688,11 +563,11 @@ function drawCardTargetOverlay(ctx, canvas, game) { ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; - rect(ctx, (260 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 420 / scale, 42 / scale, true, true); + rect(ctx, 260 - game.view.x, 96 - game.view.y, 420, 42, true, true); ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', (470 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale); + ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y); ctx.restore(); return; } @@ -701,11 +576,11 @@ function drawCardTargetOverlay(ctx, canvas, game) { ctx.lineWidth = 3; const remaining = Math.max(1, game.cardTargetPick?.remaining || 1); const msg = isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.'; - rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true); + rect(ctx, 216 - game.view.x, 96 - game.view.y, 560, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText(msg, (496 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale); + ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y); ctx.restore(); } @@ -743,7 +618,7 @@ function drawEffects(ctx, game) { } } function drawFloatingTexts(ctx, game) { - ctx.save(); ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; + ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); } ctx.restore(); } @@ -774,7 +649,7 @@ function drawRepairman(ctx, game) { ctx.beginPath(); ctx.moveTo(r.x - 11, r.y + 18); ctx.lineTo(r.x - 20, r.y + 24); ctx.stroke(); ctx.beginPath(); ctx.moveTo(r.x + 11, r.y + 18); ctx.lineTo(r.x + 20, r.y + 24); ctx.stroke(); ctx.fillStyle = THEME.ink; - ctx.font = '900 11px ui-monospace, monospace'; + ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33); if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green); @@ -784,12 +659,12 @@ function drawRepairman(ctx, game) { function drawCanvasHints(ctx, game, canvas) { ctx.save(); - ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink; + ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink; // Visible version marker helps avoid browser/file-cache confusion when testing zips. ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, canvas.width - 284, 18, 260, 34, true, true); ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40); - ctx.font = '900 19px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; + ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; const lines = []; if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2'); if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect 1 EGG route to any exit`); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..29cafcf --- /dev/null +++ b/src/styles.css @@ -0,0 +1,623 @@ +:root { + --bg: #f2fff0; + --screen: #eefbea; + --panel: rgba(245, 255, 242, 0.96); + --ink: #102015; + --muted: #526456; + --line: #102015; + --green: #22b94f; + --green-soft: #b7f5c6; + --white: #ffffff; + --danger: #c42424; + --shadow: 8px 8px 0 rgba(16, 32, 21, 0.22); +} +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; } +button { font: inherit; } +h1, h2, p { margin: 0; } +#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; } +.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); } +#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; } +.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; } +.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); } +.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); } +.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); } +.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); } +.hud-card.primary span { font-size: 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; } +.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); } +.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; } +.cash-negative { color: var(--danger) !important; } + +.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; } +.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.locked { background: #edf3ec; color: var(--muted); } +.target-brief { color: var(--green); } +.speed-brief { color: var(--ink); } + +.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; } +.panel-head { display: grid; gap: 8px; margin-bottom: 10px; } +.panel-head h1 { font-size: 15px; letter-spacing: .06em; } +.panel-head h1 span { font-size: 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; } +.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; } +.tool-button.small { min-height: 40px; padding: 6px; } +.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); } +.tool-button { min-height: 50px; text-align: left; padding: 8px; } +.tool-button strong { display: block; font-size: 12px; } +.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); } +.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); } +.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); } +.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; } +.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; } +.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; } +.facility-card h3 { color: var(--ink); font-size: 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: 9px; } +.facility-action.good { background: var(--green-soft); } +.facility-action.warn { background: #fff0d1; } +.facility-action.danger { background: #ffdada; } +.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); } +.sort-button { min-height: 46px; font-size: 12px; } +.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); } +.modal.visible { display: flex; } +.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); } +.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; } +.modal-card p { line-height: 1.55; } +.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); } +.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } +.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; } +.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; } +.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; } +.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; } +.route-card b { font-size: 24px; color: var(--green); text-align: center; } +.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; } +.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } +.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; } +.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; } +.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); } +.game-shell:not(.phase-build) .build-panel { display: none; } +.game-shell.phase-running .hud-top-right { display: none; } +.game-shell:not(.phase-running) .control-dock { display: none; } +@media (max-width: 1180px) { + #app { padding: 0; } + .game-shell { min-height: 100vh; } + #gameCanvas { height: 100vh; } + .hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); } + .priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; } + .build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; } + .large-tools { grid-template-columns: 1fr 1fr; } + .control-dock { grid-template-columns: repeat(2, 150px); } + .route-cards, .result-grid { grid-template-columns: 1fr; } +} + +.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); } +.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; } +.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; } +.contract-card p { margin: 5px 0; } +.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.declined { background: #f1f1f1; color: var(--muted); } +.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; } +.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; } +.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); } +.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); } +.result-section-title:first-child { margin-top: 0; } +.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; } + +.compact-rules .mini-box { padding: 8px; font-size: 9px; } +.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; } +.formula-box { + border: 3px solid var(--line); + background: #f7fff5; + padding: 10px; + margin: 10px 0; + line-height: 1.5; + color: var(--ink); + font-size: 12px; + font-weight: 700; +} +.equipment-menu-lines { + border: 3px solid var(--line); + background: #ffffff; + padding: 10px; + margin: 8px 0; + color: var(--muted); + line-height: 1.45; +} +.equipment-menu-lines p { margin: 4px 0; } + +/* v11 compact clicked-equipment bubble */ +.modal.equipment-popover { + background: transparent; + align-items: flex-start; + justify-content: flex-start; + padding: 0; + pointer-events: none; +} +.modal.equipment-popover .modal-card { + position: absolute; + left: var(--popover-x, 16px); + top: var(--popover-y, 16px); + width: min(250px, calc(100vw - 24px)); + max-height: min(280px, calc(100vh - 24px)); + overflow: auto; + padding: 8px; + border-width: 3px; + pointer-events: auto; + box-shadow: 6px 6px 0 rgba(16,32,21,.18); +} +.modal.equipment-popover .modal-card h2 { + font-size: 14px; + margin-bottom: 6px; +} +.modal.equipment-popover .modal-actions { + margin-top: 8px; + gap: 6px; + justify-content: flex-start; +} +.modal.equipment-popover .modal-actions button { + padding: 5px 7px; + font-size: 9px; +} +.equipment-menu-lines.compact { + padding: 7px; + margin: 6px 0; + font-size: 9px; +} +.formula-box.compact { + padding: 7px; + margin: 6px 0; + font-size: 9px; +} + +/* v15 pre-build card draft */ +.card-choices { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 220px)); + justify-content: center; + align-items: stretch; + gap: 10px; + margin-top: 14px; +} +.card-choice { + min-height: 132px; + border: 3px solid var(--line); + background: #f7fff5; + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.16); + padding: 12px; + text-align: left; + cursor: pointer; + font-weight: 900; +} +.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); } +.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; } +.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; } +.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; } +.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); } +.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); } +.card-choice.ultra-rare span { background: #c42424; color: #fff; } +.muted-card-note { color: var(--muted); font-weight: 700; } +.card-targets { display: grid; gap: 8px; margin-top: 14px; } +.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.card-target-button:hover { background: var(--green-soft); } + +/* v19 readability pass */ +body { font-size: 16px; } +.hud-card span { font-size: 11px; } +.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); } +.priority-strip > div { font-size: 14px; } +.panel-head h1 { font-size: 18px; } +.panel-head p { font-size: 11px; } +.build-panel h2 { font-size: 13px; } +.tool-button strong { font-size: 14px; } +.tool-button span { font-size: 11px; } +.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; } +.facility-card h3 { font-size: 14px; } +.sort-button { font-size: 14px; } +.modal-card h2 { font-size: 29px; } +.card-choice strong { font-size: 16px; } +.card-choice small { font-size: 13px; } + +.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); } + +.hover-tooltip { + position: fixed; + z-index: 30; + width: min(320px, calc(100vw - 20px)); + display: none; + border: 3px solid var(--line); + background: rgba(255,255,255,.96); + color: var(--ink); + box-shadow: 6px 6px 0 rgba(16,32,21,.18); + padding: 10px; + pointer-events: none; + line-height: 1.35; +} +.hover-tooltip.visible { display: block; } +.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; } +.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; } +.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; } + +.debug-panel { + position: absolute; + left: 10px; + bottom: 10px; + z-index: 12; + border: 3px solid var(--line); + background: rgba(255,255,255,.94); + box-shadow: 4px 4px 0 rgba(16,32,21,.16); + max-width: 330px; +} +.debug-panel summary { + cursor: pointer; + padding: 5px 8px; + font-weight: 900; + font-size: 12px; + user-select: none; +} +.debug-body { + display: grid; + gap: 7px; + padding: 8px; + border-top: 3px solid var(--line); + font-size: 12px; +} +.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); } +.debug-body input, .debug-body select, .debug-body button { + border: 2px solid var(--line); + background: var(--white); + color: var(--ink); + font: inherit; + font-weight: 900; + padding: 5px 6px; +} +.debug-body button { cursor: pointer; background: var(--green-soft); } + +.card-choice.dud { + background: #eeeeee; + color: #777; + filter: grayscale(1); + border-style: dashed; + box-shadow: 5px 5px 0 rgba(16,32,21,.10); +} +.card-choice.dud span { background: #f8f8f8; } +.card-choice.flung { + pointer-events: none; + transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68); + opacity: 0; + transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in; +} + +/* v20 Phase 1 observability */ +.debug-readout { + border: 2px solid var(--line); + background: rgba(247,255,245,.92); + padding: 6px; + display: grid; + gap: 3px; + line-height: 1.25; +} +.debug-readout strong { + display: block; + color: var(--ink); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .08em; +} +.debug-readout span { + display: block; + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} +.debug-readout.event-log { + max-height: 160px; + overflow: auto; +} + + +.route-card .route-icon.male, +.route-card .route-icon.female, +.route-card .route-icon.poop { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 999px; + border: 3px solid var(--line, #102015); + background: var(--white, #fff); + line-height: 1; +} +.route-card .route-icon.male { color: #2477ff; background: #d9ecff; } +.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; } +.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; } +.restart-button { min-width: 160px; } +.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; } + +/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */ +.tool-button .tool-flavor { + display: block; + max-height: 0; + opacity: 0; + overflow: hidden; + margin-top: 0; + color: var(--muted); + font-size: 8px; + line-height: 1.22; + text-transform: none; + letter-spacing: 0; + transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease; +} +.tool-button:hover .tool-flavor { + max-height: 42px; + opacity: 1; + margin-top: 5px; +} +.large-tools .tool-button { min-height: 58px; } +.large-tools .tool-button:hover { min-height: 78px; } +.history-tools .tool-button.small { min-height: 40px; } +.primary-button.cashout-emphasis, +.tool-button.cashout-emphasis { + opacity: 1 !important; + filter: none !important; + animation: cashoutPulse 0.9s ease-in-out infinite alternate; +} +.primary-button.cashout-emphasis { + box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22); +} +.tool-button.cashout-emphasis { + background: #fff0d1; + box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20); +} +@keyframes cashoutPulse { + from { transform: translate(0, 0); } + to { transform: translate(-1px, -1px); } +} +.modal.gameover-modal { padding: 12px; } +.modal.gameover-modal .modal-card { + width: min(640px, 92vw); + max-height: min(82vh, 720px); + overflow: auto; + padding: 16px; +} +.modal.gameover-modal .modal-card h2 { + font-size: 22px; + margin-bottom: 8px; +} +.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; } +.gameover-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.gameover-breakdown { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.modal.gameover-modal .result-grid div { padding: 7px; } +.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; } +.modal.gameover-modal .result-grid span { font-size: 12px; } +.gameover-details { + margin-top: 10px; + border: 3px solid var(--line); + background: #f7fff5; + padding: 8px; +} +.gameover-details summary { + cursor: pointer; + font-weight: 900; + text-transform: uppercase; +} +.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; } +@media (max-width: 720px) { + .gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; } +} + +/* v26.0 readability and stable build-menu tooltip pass */ +body { font-size: 18px; } +.build-panel { width: clamp(330px, 26vw, 420px); } +.panel-head h1 { font-size: 20px; } +.panel-head h1 span { font-size: 12px; } +.panel-head p { font-size: 13px; line-height: 1.35; } +.build-panel h2 { font-size: 14px; } +.priority-strip > div { font-size: 15px; } +.hud-card span { font-size: 12px; } +.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); } +.mini-box, .contract-card, .facility-panel-empty, .facility-card { font-size: 14px; line-height: 1.45; } +.compact-rules .mini-box { font-size: 13px; } +.tool-button { + position: relative; + overflow: visible; + min-height: 62px; + padding: 10px; +} +.large-tools .tool-button { min-height: 62px; } +.large-tools .tool-button:hover { min-height: 62px; } +.tool-button strong { font-size: 15px; line-height: 1.15; } +.tool-button span { font-size: 12px; } +.tool-button .tool-flavor { + display: none; + position: absolute; + z-index: 60; + left: 0; + top: calc(100% + 7px); + width: min(300px, calc(100vw - 40px)); + max-height: none; + opacity: 1; + overflow: visible; + margin: 0; + padding: 10px 12px; + border: 3px solid var(--line); + background: rgba(255,255,255,.98); + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.18); + font-size: 13px; + line-height: 1.35; + text-transform: none; + letter-spacing: 0; + pointer-events: none; + transition: none; +} +.tool-button .tool-flavor::before { + content: ''; + position: absolute; + left: 16px; + top: -10px; + width: 16px; + height: 16px; + border-left: 3px solid var(--line); + border-top: 3px solid var(--line); + background: rgba(255,255,255,.98); + transform: rotate(45deg); +} +.tool-button:hover .tool-flavor { + display: block; + max-height: none; + opacity: 1; + margin: 0; +} +.primary-button { font-size: 16px; padding: 13px 16px; } +#nextTurnButton { min-height: 56px; font-size: 18px; } +@keyframes cashoutPulse { + from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); } + to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); } +} +.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; } +.card-choice { min-height: 164px; padding: 15px; } +.card-choice strong { font-size: 18px; } +.card-choice span { font-size: 11px; } +.card-choice small { font-size: 15px; line-height: 1.4; } +.muted-card-note { font-size: 15px; line-height: 1.45; } +.modal-card { width: min(980px, 96vw); } +.modal-card p, .modal-card li { font-size: 16px; } +.modal-actions .reroll-button { + order: 99; + flex: 1 1 100%; + min-height: 64px; + margin-top: 8px; + font-size: 20px; + text-align: center; + justify-content: center; + background: #fff0d1; + box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20); +} +.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); } +.hover-tooltip strong { font-size: 17px; } +.hover-tooltip span { font-size: 14px; } +.sort-button { font-size: 16px; min-height: 56px; } +.control-dock { grid-template-columns: repeat(4, 172px); } +@media (max-width: 1180px) { + .build-panel { width: auto; } + .control-dock { grid-template-columns: repeat(2, 172px); } +} + +/* v27 combo / tags / settlement clarity */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0 0 8px; +} +.card-tags i { + display: inline-block; + border: 2px solid var(--line); + background: rgba(255,255,255,.86); + color: var(--ink); + font-style: normal; + font-size: 10px; + font-weight: 900; + padding: 1px 6px; + letter-spacing: .05em; +} +.settlement-hero { + border: 4px solid var(--line); + background: #f7fff5; + padding: 14px; + margin-bottom: 12px; + box-shadow: 5px 5px 0 rgba(16,32,21,.14); +} +.settlement-hero.positive { box-shadow: inset 0 0 0 4px var(--green), 5px 5px 0 rgba(16,32,21,.14); } +.settlement-hero.negative { box-shadow: inset 0 0 0 4px var(--danger), 5px 5px 0 rgba(16,32,21,.14); } +.settlement-hero strong { display: block; font-size: 34px; line-height: 1; color: var(--green); } +.settlement-hero.negative strong { color: var(--danger); } +.settlement-hero span { display: block; margin-top: 8px; font-weight: 900; color: var(--muted); } +.settlement-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.settlement-grid div { + border: 3px solid var(--line); + background: rgba(255,255,255,.9); + padding: 9px; +} +.settlement-grid strong { display: block; font-size: 11px; text-transform: uppercase; color: var(--muted); } +.settlement-grid span { display: block; margin-top: 5px; font-size: 20px; font-weight: 900; color: var(--ink); } +.settlement-table { + width: 100%; + border-collapse: collapse; + border: 3px solid var(--line); + background: #fff; + margin: 10px 0; +} +.settlement-table td { + border-bottom: 2px solid rgba(16,32,21,.18); + padding: 7px 9px; + font-size: 15px; + font-weight: 900; +} +.settlement-table td:last-child { text-align: right; } +.settlement-table tr.income td:last-child { color: var(--green); } +.settlement-table tr.cost td:last-child { color: var(--danger); } +.settlement-notes { + display: grid; + gap: 5px; + border: 3px solid var(--line); + background: #f7fff5; + padding: 10px; + color: var(--muted); + font-weight: 800; +} +.key-config-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin: 10px 0; +} +.key-config-grid label { + display: grid; + gap: 4px; + font-weight: 900; + color: var(--muted); +} +.key-config-grid input, +.key-config-grid select { + border: 3px solid var(--line); + background: var(--white); + color: var(--ink); + padding: 7px 8px; + font: inherit; + font-weight: 900; +} +.modal.rescue-modal .modal-card { + width: min(560px, 92vw); +} +.hover-tooltip .cash-negative { color: var(--danger) !important; font-weight: 900; } +@media (max-width: 720px) { + .settlement-grid { grid-template-columns: 1fr 1fr; } + .settlement-hero strong { font-size: 26px; } +} diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index b0ebb7f..3d5804b 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,12 +1,9 @@ -import { BALANCE } from '../core/balance.js'; import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; import { getSpawnRange } from '../core/state.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; -import { generateBlockedCellsInRect } from '../core/mapGen.js'; -import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js'; -import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js'; +import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; import { TEXT, equipmentName } from '../core/text.js'; -import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js'; +import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js'; import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; import { autoScannerCooldownSeconds } from './cards.js'; import { record } from './history.js'; @@ -17,95 +14,8 @@ import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScanne export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } - let lastConveyorBuildCell = null; - let conveyorDragRecordedDirectionEdit = false; - - function gridExpansionCost() { return expansionCost(game); } - - function syncGridSizeToGame() { - game.gridCols = GRID.cols; - game.gridRows = GRID.rows; - game.gridOwnedCells = countOwnedCells(game); - } - - function shiftWorldRowsDown(rowDelta) { - if (!rowDelta) return; - GRID.y -= rowDelta * GRID.cell; - GRID.rows += rowDelta; - game.gridChunkOriginY = (game.gridChunkOriginY || 0) - Math.floor(rowDelta / 10); - game.ownedCells = shiftRowIndexedSet(game.ownedCells, rowDelta); - game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta); - game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta); - game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta); - for (const farm of game.eggFarms || []) farm.row += rowDelta; - for (const scanner of game.scanners || []) scanner.row += rowDelta; - for (const facility of Object.values(game.facilities || {})) { - if (facility.entry) facility.entry.row += rowDelta; - } - } - - function addOwnedLotCells(lot) { - game.ownedCells = game.ownedCells || new Set(); - for (const cell of cellsInLot(lot)) game.ownedCells.add(key(cell.col, cell.row)); - } - - function expansionOfferAtPoint(p) { - if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null; - return expansionLots(game).find(lot => lot && lotContainsPoint(lot, p)) || null; - } - - function resolveExpansionRequest(request = 'right') { - if (request && typeof request === 'object' && Number.isFinite(request.chunkX) && Number.isFinite(request.chunkY)) return request; - const direction = ['right', 'up'].includes(request) ? request : 'right'; - return expansionLot(game, direction); - } - - function buyGridExpansion(request = 'right') { - const offer = resolveExpansionRequest(request); - if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return { ok: false, reason: 'Cannot expand now.' }; - if (!offer) return { ok: false, reason: 'Invalid expansion lot.' }; - const direction = offer.direction || 'right'; - const cost = gridExpansionCost(); - if (game.cash < cost) { - const reason = `Need ${yen(cost - game.cash)} more.`; - fail(reason); - return { ok: false, reason }; - } - record(game); - spendCash(game, cost); - if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0 }; - const originY = game.gridChunkOriginY || 0; - if (offer.chunkY < originY) { - const chunksToAdd = originY - offer.chunkY; - shiftWorldRowsDown(chunksToAdd * (BALANCE.grid.expansion?.rowsPerPurchase || 10)); - } - const lot = lotFromChunk(game, offer.chunkX, offer.chunkY, direction); - if (lot.colStart + lot.cols > GRID.cols) GRID.cols = lot.colStart + lot.cols; - if (lot.rowStart + lot.rows > GRID.rows) GRID.rows = lot.rowStart + lot.rows; - addOwnedLotCells(lot); - ownChunk(game, lot.chunkX, lot.chunkY); - game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1; - game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1; - syncGridSizeToGame(); - const blocked = generateBlockedCellsInRect({ - seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`, - colStart: lot.colStart, - colEnd: lot.colStart + lot.cols, - rowStart: lot.rowStart, - rowEnd: lot.rowStart + lot.rows, - targetRatio: BALANCE.map.blockedRatio - }); - for (const k of blocked) game.blockedCells.add(k); - refreshRoutingAfterEdit(game); - const addedCells = lot.cols * lot.rows; - const b = lotBounds(lot); - floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green); - updatePanels(); - return { ok: true, direction, cost, addedCells, blocked: blocked.size, chunkX: lot.chunkX, chunkY: lot.chunkY }; - } - - function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); } - function isBlockedCell(col, row) { return !isOwnedCell(game, col, row) || game.blockedCells?.has?.(key(col, row)); } + function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; } + function isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); } function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; } function rectsOverlap(a, b, margin = 10) { @@ -178,138 +88,33 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return ''; } + function canSellHit(hit) { + return !lastProtectedSaleReason(hit); + } + function fail(reason) { ui.buildStatus.textContent = reason; floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger); } - function conveyorMetaForCell(cell) { - if (!cell) return null; - const k = key(cell.col, cell.row); - return game.conveyorMeta.get(k) || null; - } - - function normalizeOutDirs(meta) { - if (!meta) return []; - if (!Array.isArray(meta.outDirs)) meta.outDirs = []; - meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))]; - return meta.outDirs; - } - - function markConveyorDirection(from, to) { - const dir = directionNameBetweenCells(from, to); - if (!dir) return false; - const fromKey = key(from.col, from.row); - const toKey = key(to.col, to.row); - const fromMeta = game.conveyorMeta.get(fromKey); - const toMeta = game.conveyorMeta.get(toKey); - if (!fromMeta || !toMeta) return false; - const outDirs = normalizeOutDirs(fromMeta); - const changed = !outDirs.includes(dir) || fromMeta.dir !== dir || !toMeta.dir; - if (!outDirs.includes(dir)) outDirs.push(dir); - fromMeta.dir = dir; - if (!toMeta.dir) toMeta.dir = dir; - return changed; - } - - function buildConveyorCell(cell, incomingDir = null) { - if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid }; - const { col, row } = cell; - const k = key(col, row); - if (game.conveyorTiles.has(k)) return { ok: false, exists: true }; - if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' }; - if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied }; - const cost = buildPrice('conveyor', game); - if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash }; - record(game); - spendCash(game, cost); - game.conveyorTiles.add(k); - const quality = buildQualityPatch(); - game.conveyorMeta.set(k, { - price: cost, - builtSession: game.buildSession, - uses: 0, - durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), - maintenanceType: 'conveyor', - dir: incomingDir || null, - outDirs: [], - ...quality - }); - game.selected = { type: 'conveyor', id: k }; - floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); - return { ok: true, cell, key: k }; - } - - function handleConveyorDragCell(cell) { - if (!cell) return fail(TEXT.fail.outOfGrid); - const currentKey = key(cell.col, cell.row); - const exists = game.conveyorTiles.has(currentKey); - const previous = lastConveyorBuildCell; - if (previous && previous.col === cell.col && previous.row === cell.row) return; - - if (exists) { - if (previous && markConveyorDirection(previous, cell)) { - if (!conveyorDragRecordedDirectionEdit) { - // Direction edits are intentionally free; record once per drag gesture so undo can restore a major reroute. - record(game); - conveyorDragRecordedDirectionEdit = true; - } - refreshRoutingAfterEdit(game); - } - game.selected = { type: 'conveyor', id: currentKey }; - lastConveyorBuildCell = { col: cell.col, row: cell.row }; - return; - } - - const incomingDir = previous ? directionNameBetweenCells(previous, cell) : null; - const result = buildConveyorCell(cell, incomingDir); - if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied); - if (previous) markConveyorDirection(previous, cell); - lastConveyorBuildCell = { col: cell.col, row: cell.row }; - refreshRoutingAfterEdit(game); - } - - function stepTowardCell(from, to) { - if (!from || !to) return to; - const dc = to.col - from.col; - const dr = to.row - from.row; - if (dc === 0 && dr === 0) return from; - if (Math.abs(dc) >= Math.abs(dr)) return { col: from.col + Math.sign(dc), row: from.row }; - return { col: from.col, row: from.row + Math.sign(dr) }; - } - - function beginConveyorDrag(cell) { - conveyorDragRecordedDirectionEdit = false; - lastConveyorBuildCell = null; - handleConveyorDragCell(cell); - } - - function continueConveyorDrag(cell) { - if (!cell) return; - if (!lastConveyorBuildCell) return handleConveyorDragCell(cell); - let cursor = lastConveyorBuildCell; - let guard = 0; - while ((cursor.col !== cell.col || cursor.row !== cell.row) && guard < 64) { - const next = stepTowardCell(cursor, cell); - handleConveyorDragCell(next); - cursor = lastConveyorBuildCell || next; - guard += 1; - if (!lastConveyorBuildCell) break; - } - } - - function endConveyorDrag() { - lastConveyorBuildCell = null; - conveyorDragRecordedDirectionEdit = false; - } - function buildAtCell(cell) { if (!cell) return fail(TEXT.fail.outOfGrid); const { col, row } = cell; if (game.buildTool === 'conveyor') { - const result = buildConveyorCell(cell, null); - if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied); + if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied); + if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); + if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied); + const cost = buildPrice('conveyor', game); + if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); + record(game); + spendCash(game, cost); + const k = key(col, row); + game.conveyorTiles.add(k); + const quality = buildQualityPatch(); + game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), maintenanceType: 'conveyor', ...quality }); + game.selected = { type: 'conveyor', id: k }; refreshRoutingAfterEdit(game); + floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); return; } if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); @@ -339,7 +144,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } function buildScanner(col, row, kind) { - if (kind === 'manual' && game.scanners.filter(s => s.kind === 'manual').length >= 8) return fail('Manual Scanner limit reached (8 max).'); const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); record(game); @@ -477,88 +281,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel ui.modal.classList.add('visible', 'equipment-popover'); } - function formatKeyBinding(binding) { - return binding?.label || binding?.key || '?'; - } - - function bindingFromEvent(event) { - const label = event.key === ' ' ? 'Space' : event.key.length === 1 ? event.key.toUpperCase() : event.key.replace(/^Arrow/, ''); - return { key: event.key.toLowerCase(), code: event.code, label }; - } - - function keyConflict(scanner, side, binding) { - for (const s of game.scanners.filter(item => item.kind === 'manual')) { - for (const candidateSide of ['left', 'right']) { - if (s.id === scanner.id && candidateSide === side) continue; - const existing = s.keys?.[candidateSide]; - if (existing && (existing.code === binding.code || existing.key === binding.key)) return true; - } - } - return false; - } - - function showManualScannerMenu(scanner, message = '') { - if (!scanner || scanner.kind !== 'manual') return; - ui.modalTitle.textContent = `Manual Scanner #${scanner.id}`; - const left = formatKeyBinding(scanner.keys?.left); - const right = formatKeyBinding(scanner.keys?.right); - const conflict = manualKeyConflicts(scanner); - ui.modalBody.innerHTML = ` -
-

Left route key: ${left}

-

Right route key: ${right}

-

Role: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}

- ${message ? `

${message}

` : ''} - ${conflict ? `

${conflict}

` : ''} -
`; - ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(modalButton('Set Left Key', () => captureManualKey(scanner, 'left'), 'facility-action warn')); - ui.modalActions.appendChild(modalButton('Set Right Key', () => captureManualKey(scanner, 'right'), 'facility-action warn')); - ui.modalActions.appendChild(modalButton('Close', hideModal, 'facility-action')); - positionEquipmentPopover(scanner); - ui.modal.classList.add('visible', 'equipment-popover'); - } - - function manualKeyConflicts(scanner) { - const own = [ - { side: 'left', binding: scanner.keys?.left }, - { side: 'right', binding: scanner.keys?.right } - ]; - if (own[0].binding && own[1].binding && (own[0].binding.code === own[1].binding.code || own[0].binding.key === own[1].binding.key)) return 'Left and right keys conflict.'; - for (const item of own) { - if (!item.binding) continue; - for (const other of game.scanners.filter(s => s.kind === 'manual' && s.id !== scanner.id)) { - if (other.keys?.left && (other.keys.left.code === item.binding.code || other.keys.left.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} left.`; - if (other.keys?.right && (other.keys.right.code === item.binding.code || other.keys.right.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} right.`; - } - } - return ''; - } - - function captureManualKey(scanner, side) { - ui.modalBody.innerHTML = `

Press a key for ${side.toUpperCase()} route.

Esc cancels.

`; - ui.modalActions.innerHTML = ''; - const onKey = event => { - event.preventDefault(); - event.stopPropagation(); - window.removeEventListener('keydown', onKey, true); - if (event.key === 'Escape') return showManualScannerMenu(scanner); - const binding = bindingFromEvent(event); - const conflict = keyConflict(scanner, side, binding); - record(game); - scanner.keys = scanner.keys || { left: null, right: null }; - scanner.keys[side] = binding; - updatePanels(); - showManualScannerMenu(scanner, conflict ? `${binding.label} assigned, but another scanner already uses it.` : `${side.toUpperCase()} set to ${binding.label}.`); - }; - window.addEventListener('keydown', onKey, true); - } - function switchScannerRole() { const obj = selectedObject(); if (!obj || obj.type !== 'scanner') return; if (obj.kind === 'auto') return showAutoScannerMenu(obj); - return showManualScannerMenu(obj); + updatePanels(); } function selectedPopoverWorldPoint(obj) { @@ -573,9 +300,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function positionEquipmentPopover(obj) { const rect = canvas.getBoundingClientRect(); const p = selectedPopoverWorldPoint(obj); - const scale = game.view?.scale || 1; - const screenX = rect.left + ((p.x * scale + (game.view?.x || 0)) / canvas.width) * rect.width + 18; - const screenY = rect.top + ((p.y * scale + (game.view?.y || 0)) / canvas.height) * rect.height - 12; + const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18; + const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12; const maxX = Math.max(12, window.innerWidth - 274); const maxY = Math.max(12, window.innerHeight - 220); ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`); @@ -592,7 +318,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel ${['mixer', 'truck'].includes(obj.id) ? '

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

' : ''}`; ui.modalActions.innerHTML = ''; if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn')); - if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn')); const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj }; const saleReason = lastProtectedSaleReason(hit); if (saleReason) { @@ -610,7 +335,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function setBuildTool(tool) { if (game.phase !== 'build') return; - endConveyorDrag(); game.buildTool = game.buildTool === tool ? null : tool; game.selected = null; game.multiSelected = []; @@ -658,7 +382,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel lines.push(`Cell: ${obj.id}`); lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`); - lines.push('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.'); + lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.'); 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))}`); @@ -686,13 +410,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return lines; } - function disconnectedWarningFor(obj) { - if (!obj || game.phase !== 'build') return ''; - const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id); - if (!match) return ''; - return match.message || 'Not connected to conveyor route'; - } - function flavorText(obj) { if (!obj) return ''; @@ -715,10 +432,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel updateGroupDrag: selection.updateGroupDrag, finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, - selectedInfoLines, flavorText, disconnectedWarningFor, - gridExpansionCost, buyGridExpansion, expansionOfferAtPoint, - beginConveyorDrag, continueConveyorDrag, endConveyorDrag, - removeSelected, switchScannerRole, showManualScannerMenu, + selectedInfoLines, flavorText, + removeSelected, switchScannerRole, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, routeFromFarmToScanner diff --git a/src/systems/cards.js b/src/systems/cards.js index 2ea3553..e897fb3 100644 --- a/src/systems/cards.js +++ b/src/systems/cards.js @@ -1,4 +1,4 @@ -import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js'; +import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js'; import { nextSpawnDelay } from '../core/state.js'; import { cellCenter, key, yen } from '../core/utils.js'; import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; @@ -11,7 +11,193 @@ const RARE_WEIGHT = CARD_BALANCE.rareWeight; const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45; const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize; -export const CARD_DEFS = CARD_DEFINITIONS; +const DEFAULT_EFFECTS = Object.freeze({ + bearing: 0, + legalWork: 0, + fairiesFlatteryNext: 0, + crowdedFarming: 0, + extraEggOutlet: 0, + hatchingFeed: 0, + safetyCover: 0, + recyclingSubsidy: 0, + chemicalWeaponSubsidy: 0, + preventiveMaintenance: 0, + durabilityCoating: 0, + laborExploitation: 0, + usedMachineActive: false, + rescueLoanCharges: 0, + loans: [] +}); + +export const CARD_DEFS = [ + { + id: 'upgradeEgg', + title: 'EGG Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'eggFarm', + description: 'Choose one EGG FARM on the map and raise it by 1 level.' + }, + { + id: 'upgradeAutoScanner', + title: 'AUTO SCANNER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'autoScanner', + description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.' + }, + { + id: 'upgradeMixer', + title: 'MIXER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'mixer', + description: 'Choose MIXER on the map and raise it by 1 level.' + }, + { + id: 'upgradeTruck', + title: 'TRUCK Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'truck', + description: 'Choose TRUCK on the map and raise it by 1 level.' + }, + { + id: 'upgradeTrash', + title: 'SHREDDER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'trash', + description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.' + }, + { + id: 'crowdedFarming', + title: 'Crowded Farming Contract', + rarity: 'common', + type: 'instant', + description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.' + }, + { + id: 'hatchingFeed', + title: 'Hatching Feed', + rarity: 'common', + type: 'instant', + description: 'EGG production interval -4%. Poop rate +3%. No card limit.' + }, + { + id: 'safetyCover', + title: 'Safety Cover', + rarity: 'common', + type: 'instant', + description: 'Explosion damage -5%. No card limit.' + }, + { + id: 'preventiveMaintenance', + title: 'Preventive Maintenance Manual', + rarity: 'common', + type: 'instant', + description: 'All equipment deterioration speed -5%. No card limit.' + }, + { + id: 'durabilityCoating', + title: 'Durability Coating', + rarity: 'common', + type: 'instant', + description: 'All equipment maximum durability +3%. No card limit.' + }, + { + id: 'bearing', + title: 'High-Quality Bearing', + rarity: 'common', + type: 'instant', + description: 'Conveyor speed +7.5%.' + }, + { + id: 'extraEggOutlet', + title: 'Extra Egg Outlet', + rarity: 'rare', + type: 'instant', + description: 'EGG output ports +1. Each connected belt can receive eggs. Max 3 cards.' + }, + { + id: 'recyclingSubsidy', + title: 'Recycling Subsidy', + rarity: 'rare', + type: 'instant', + description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.' + }, + { + id: 'dynamite', + title: 'Dynamite', + rarity: 'rare', + type: 'cellAction', + target: 'blockedCell', + description: 'Remove any 3 blocked cells with a burst effect.' + }, + { + id: 'usedMachine', + title: 'Used Machines', + rarity: 'rare', + type: 'instant', + description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.' + }, + { + id: 'newMachine', + title: 'New Machines', + rarity: 'common', + type: 'instant', + description: 'Cancel Used Machines and restore normal prices, refunds, and durability.' + }, + { + id: 'flattery', + title: 'Flattery', + rarity: 'rare', + type: 'instant', + description: 'Next Fairies tribute -50%.' + }, + { + id: 'loan', + title: 'Loan', + rarity: 'rare', + type: 'instant', + description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.' + }, + { + id: 'extraCards', + title: 'Extra Cards', + rarity: 'rare', + type: 'instant', + description: 'Draw 2 cards now and choose both.' + }, + { + id: 'legalWork', + title: 'Legal Work', + rarity: 'rare', + type: 'instant', + description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.' + }, + { + id: 'chemicalWeaponSubsidy', + title: 'Chemical Weapons Subsidy', + rarity: 'ultraRare', + type: 'instant', + description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.' + }, + { + id: 'laborExploitation', + title: 'Motivational Exploitation', + rarity: 'ultraRare', + type: 'instant', + description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.' + }, + { + id: 'rescueLoan', + title: 'Emergency Loan', + rarity: 'ultraRare', + type: 'instant', + description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.' + } +]; function effectCount(game, id) { const effects = ensureCardState(game); @@ -19,8 +205,8 @@ function effectCount(game, id) { } export function ensureCardState(game) { - if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] }; - for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) { + if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] }; + for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) { if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v; } if (game.cardEffects.flattery != null) { @@ -40,8 +226,12 @@ export function eggProductionDelayMultiplier(game) { return crowded * feed; } -export function extraEggOutletCount(_game, farm = null) { - return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0)); +export function poopRateCardMultiplier(game) { + return Math.pow(1.03, effectCount(game, 'hatchingFeed')); +} + +export function extraEggOutletCount(game) { + return Math.min(3, effectCount(game, 'extraEggOutlet')); } export function conveyorSpeedForGame(game, conveyorKey = null) { @@ -57,6 +247,13 @@ export function autoScannerCooldownSeconds(scanner, game = null) { return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base; } +export function flatteryReductionForTurn(game, turn = game.turn) { + const day = Math.max(1, Math.floor(Number(turn) || 1)); + const base = Math.ceil(day * 10); + const charges = Math.max(0, ensureCardState(game).fairiesFlatteryNext || 0); + return charges > 0 ? Math.ceil(base * (1 - Math.pow(0.5, charges))) : 0; +} + export function rerollCost(game) { const draft = game.cardDraft || { rerolls: 0 }; const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1); @@ -67,48 +264,11 @@ export function cardById(id) { return CARD_DEFS.find(card => card.id === id) || null; } -export function debugGrantCard(game, cardOrId) { - const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId; - ensureCardState(game); - if (!card || card.type === 'dud') return { ok: false, reason: 'Card not found.' }; - if (card.id === 'extraCards') { - game.cardDraft = game.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }; - game.cardDraft.choices.push(...dealCards(game, 2)); - return { ok: true, reason: `${card.title} added draft cards.` }; - } - if (card.type === 'instant') { - applyInstantCard(game, card); - return { ok: true, reason: `${card.title} granted.` }; - } - if (card.type === 'equipmentUpgrade') { - const target = targetsForCard(game, card)[0]; - if (!target) return { ok: false, reason: `No valid target for ${card.title}.` }; - applyEquipmentUpgrade(game, target); - return { ok: true, reason: `${card.title} applied.` }; - } - if (card.type === 'cellAction' && card.target === 'blockedCell') { - const targets = targetsForCard(game, card).slice(0, 3); - if (!targets.length) return { ok: false, reason: 'No blocked cells to remove.' }; - for (const target of targets) { - game.blockedCells.delete(key(target.col, target.row)); - const c = cellCenter(target.col, target.row); - eraseEffect(game, c.x, c.y); - } - refreshRoutingAfterEdit(game); - return { ok: true, reason: `${card.title} removed ${targets.length} blocked cells.` }; - } - return { ok: false, reason: `${card.title} cannot be debug-granted.` }; -} - function targetLabel(game, target) { if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`; if (target.type === 'eggFarm') { return `EGG #${target.id} L${target.level} -> L${target.level + 1}`; } - if (target.type === 'eggOutlet') { - const held = Math.max(0, Number(target.farm?.extraEggOutlet) || 0); - return `EGG #${target.id} OUTLET ${1 + held} -> ${2 + held}`; - } if (target.type === 'scanner') { return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`; } @@ -151,9 +311,6 @@ export function targetsForCard(game, cardOrId) { if (!card) return []; if (card.target === 'blockedCell') return blockedCellTargets(game); if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4); - if (card.target === 'eggOutlet') return game.eggFarms - .filter(f => Math.max(0, Number(f.extraEggOutlet) || 0) < 3) - .map(f => ({ type: 'eggOutlet', id: f.id, col: f.col, row: f.row, farm: f })); if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto'); if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : []; if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : []; @@ -164,26 +321,16 @@ export function targetsForCard(game, cardOrId) { function cardAtCap(game, card) { const effects = ensureCardState(game); if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10; + if (card.id === 'extraEggOutlet') return (effects.extraEggOutlet || 0) >= 3; if (card.id === 'recyclingSubsidy') return (effects.recyclingSubsidy || 0) >= 3; - if (card.id === 'dudFilter') { - const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); - const step = Math.max(0.0001, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05)); - return (effects.dudFilter || 0) >= Math.ceil(base / step); - } if (card.id === 'laborExploitation') return !!effects.laborExploitation; if (card.id === 'usedMachine') return !!effects.usedMachineActive; if (card.id === 'newMachine') return !effects.usedMachineActive; return false; } -function repairmanCardVisible(game) { - const r = game.repairman || {}; - return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0); -} - function availableCards(game) { return CARD_DEFS.filter(card => { - if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false; if (cardAtCap(game, card)) return false; if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false; return true; @@ -218,18 +365,11 @@ function dudCard() { }; } -function dudChanceForGame(game) { - const effects = ensureCardState(game); - const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); - const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0)); - return Math.max(0, base - step * Math.max(0, effects.dudFilter || 0)); -} - -function applyDudsToChoices(game, choices) { +function applyDudsToChoices(choices) { const result = [...choices]; let duds = 0; const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0); - const chance = dudChanceForGame(game); + const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); for (let i = 0; i < result.length && duds < maxDuds; i += 1) { if (Math.random() < chance) { result[i] = dudCard(); @@ -252,14 +392,13 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) { const i = pool.findIndex(card => card.id === picked.id); if (i >= 0) pool.splice(i, 1); } - return applyDudsToChoices(game, choices); + return applyDudsToChoices(choices); } function targetKey(target) { if (!target) return ''; if (target.type === 'blockedCell') return `blockedCell:${target.id}`; if (target.type === 'eggFarm') return `eggFarm:${target.id}`; - if (target.type === 'eggOutlet') return `eggOutlet:${target.id}`; if (target.type === 'scanner') return `scanner:${target.id}`; if (target.type === 'facility') return `facility:${target.id}`; return `${target.type}:${target.id}`; @@ -271,7 +410,7 @@ function boundsForTarget(target) { const c = cellCenter(target.col, target.row); return { x: c.x - GRID.cell * 0.48, y: c.y - GRID.cell * 0.48, w: GRID.cell * 0.96, h: GRID.cell * 0.96, cx: c.x, cy: c.y }; } - if (target.type === 'eggFarm' || target.type === 'eggOutlet') { + if (target.type === 'eggFarm') { const c = cellCenter(target.col, target.row); return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y }; } @@ -304,15 +443,6 @@ function targetAtPoint(game, p) { } function applyEquipmentUpgrade(game, target) { - if (target.type === 'eggOutlet') { - const farm = target.farm || game.eggFarms.find(f => f.id === target.id); - if (!farm) return; - farm.extraEggOutlet = Math.min(3, Math.max(0, Number(farm.extraEggOutlet) || 0) + 1); - const c = cellCenter(farm.col, farm.row); - floating(game, c.x, c.y - 30, `OUTLET ${1 + farm.extraEggOutlet}`, THEME.green); - refreshRoutingAfterEdit(game); - return; - } target.level = (target.level || 1) + 1; if (target.type === 'eggFarm') { target.nextSpawn = nextSpawnDelay(target); @@ -337,12 +467,12 @@ function applyInstantCard(game, card) { if (card.id === 'bearing') inc('bearing'); if (card.id === 'legalWork') inc('legalWork'); if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1); + if (card.id === 'extraEggOutlet') effects.extraEggOutlet = Math.min(3, (effects.extraEggOutlet || 0) + 1); if (card.id === 'hatchingFeed') inc('hatchingFeed'); if (card.id === 'safetyCover') inc('safetyCover'); if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1); if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy'); if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance'); - if (card.id === 'dudFilter') inc('dudFilter'); if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); } if (card.id === 'flattery') inc('fairiesFlatteryNext'); if (card.id === 'usedMachine') effects.usedMachineActive = true; @@ -350,7 +480,13 @@ function applyInstantCard(game, card) { if (card.id === 'laborExploitation') effects.laborExploitation = 1; if (card.id === 'rescueLoan') { inc('rescueLoanCharges'); - floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green); + if (game.cash < 0) { + effects.rescueLoanCharges = Math.max(0, (effects.rescueLoanCharges || 0) - 1); + applyRevenue(game, 500); + floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, '+¥500 RESCUE', THEME.green); + } else { + floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green); + } } if (card.id === 'loan') { const day = Math.max(1, Math.floor(Number(game.turn) || 1)); @@ -361,6 +497,11 @@ function applyInstantCard(game, card) { } } +function formatMultiplier(value) { + const rounded = Math.round(Number(value || 0) * 100) / 100; + return `x${rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')}`; +} + function formatPercent(value) { return `${Math.round(Number(value || 0) * 100)}%`; } @@ -413,24 +554,12 @@ function cardDescription(game, card) { const next = Math.min(10, (e.crowdedFarming || 0) + 1); return `Held ${e.crowdedFarming || 0}/10. After pick: EGG interval ${formatPercent(Math.max(0.5, 1 - 0.05 * next))}, deterioration ${formatPercent(Math.min(1.4, 1 + 0.04 * next))}.`; } - if (card.id === 'extraEggOutlet') { - const targets = targetsForCard(game, card); - const held = Math.max(0, Number(targets[0]?.farm?.extraEggOutlet) || 0); - return `Choose one EGG. Output ports ${1 + held} -> ${2 + held}. Max 4 ports per EGG.`; - } + if (card.id === 'extraEggOutlet') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`; if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`; if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`; if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`; if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`; if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`; - if (card.id === 'dudFilter') { - const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); - const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05)); - const held = Math.max(0, e.dudFilter || 0); - const before = Math.max(0, base - step * held); - const after = Math.max(0, base - step * (held + 1)); - return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`; - } if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`; if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`; if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.'; @@ -603,8 +732,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'ultraRare' ? 'ultra-rare' : (card.rarity === 'rare' ? 'rare' : 'common')); b.className = `card-choice ${kind}`; const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON')); - const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5); - b.innerHTML = `${card.title}
${tags.map(tag => `${tag}`).join('')}
${cardDescription(game, card)}`; + b.innerHTML = `${card.title}${rarity}${cardDescription(game, card)}`; b.addEventListener('click', () => chooseCard(card, b)); b.dataset.index = String(index); return b; diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index de6546e..5849327 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -1,9 +1,9 @@ -import { DIRS, GRID, THEME } from '../core/config.js'; +import { GRID, THEME } from '../core/config.js'; import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; -import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js'; -import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js'; -import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js'; +import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; +import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; +import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js'; import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.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'; @@ -17,23 +17,35 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function spawnChick(farm) { const farmCenter = cellCenter(farm.col, farm.row); - const maxOutputs = 1 + extraEggOutletCount(game, farm); - const starts = farmOutputStarts(farm, maxOutputs); - if (!starts.length) { + const maxOutputs = 1 + extraEggOutletCount(game); + const routes = []; + const usedStarts = new Set(); + const maxAttempts = Math.max(maxOutputs * 5, 5); + for (let attempt = 0; attempt < maxAttempts && routes.length < maxOutputs; attempt += 1) { + const data = routeFromFarmToScanner(game, farm, true); + if (!data) break; + const start = data.route?.[0]; + if (!start) continue; + const startKey = `${Math.round(start.x)}:${Math.round(start.y)}`; + if (usedStarts.has(startKey) && routes.length > 0) continue; + usedStarts.add(startKey); + routes.push(data); + } + if (!routes.length) { suppressUnconnectedFarmSpawn(farm, farmCenter); return true; } let spawned = 0; let blocked = 0; - for (const startCell of starts) { - const start = cellCenter(startCell.col, startCell.row); + for (const data of routes) { + const start = data.route[0]; const blocker = spawnTileBlocker(start); if (blocker) { blocker.stoppedTimer = 0.85; blocked += 1; continue; } - const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] }); + const chick = createChick(game, data); if (chick.sex === 'poop') countPoopSpawned(game); game.chicks.push(chick); recordFarmProduction(game, farm); @@ -54,15 +66,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } } - function farmOutputStarts(farm, limit) { - const starts = DIRS - .map(d => ({ col: farm.col + d.dc, row: farm.row + d.dr })) - .filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))); - const scored = starts.map(p => ({ p, blocked: targetBlockedByChick(null, cellCenter(p.col, p.row)) ? 1 : 0, roll: Math.random() })); - scored.sort((a, b) => a.blocked - b.blocked || a.roll - b.roll); - return scored.slice(0, Math.max(1, limit || 1)).map(item => item.p); - } - function spawnTileBlocker(start) { return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null; @@ -135,132 +138,32 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } moveAlongRoute(chick, dt); if (chick.route && chick.targetIndex < chick.route.length) return; - if (chick.pendingScannerId) { - const scanner = scannerById(game, chick.pendingScannerId); - chick.pendingScannerId = null; - if (!scanner) removeChick(index, 'NO SCANNER'); - else enqueueChick(chick, scanner, chick.route); + if (chick.stage === 'input' || chick.stage === 'toScanner') { + const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId); + if (!scanner) { + removeChick(index, 'NO SCANNER'); + return; + } + enqueueChick(chick, scanner, chick.route); return; } - advanceByCurrentConveyor(chick, index); + if (chick.stage === 'toMixer') resolveMixer(index); + else if (chick.stage === 'toTruck') resolveTruck(index); + else if (chick.stage === 'toTrash') resolveTrash(index); } function blockedByFrontChick(chick) { if (!chick.route || chick.targetIndex >= chick.route.length) return false; const next = chick.route[chick.targetIndex]; - return targetBlockedByChick(chick, next); - } - - function targetBlockedByChick(chick, target) { - if (!target) return false; for (const other of game.chicks) { - if (chick && other.id === chick.id) continue; - if (other.stage === 'flying') continue; - if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true; + if (other.id === chick.id || other.stage === 'flying') continue; + if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true; } return false; } - function appendPoint(points, p) { - if (!p) return; - const last = points[points.length - 1]; - if (!last || Math.hypot(last.x - p.x, last.y - p.y) > 1) points.push({ x: p.x, y: p.y }); - } - - function conveyorOutDirNames(cellKey) { - const meta = game.conveyorMeta?.get?.(cellKey) || {}; - const dirs = []; - if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs); - if (meta.dir) dirs.push(meta.dir); - return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir)); - } - - function dirByName(name) { - return DIRS.find(d => d.name === name) || null; - } - - function scannerAtBodyCell(col, row) { - return game.scanners.find(scanner => scanner.col === col && scanner.row === row) || null; - } - - function scannerReceivingFrom(cell, dirName) { - const d = dirByName(dirName); - if (!d) return null; - const scanner = scannerAtBodyCell(cell.col + d.dc, cell.row + d.dr); - if (!scanner) return null; - const input = scannerConnector(scanner, 'inputA'); - return input && input.col === cell.col && input.row === cell.row ? scanner : null; - } - - function facilityAtEntryCell(cell) { - return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null; - } - - function dirExitsToFacility(facility, dirName) { - return (facility.side === 'left' && dirName === 'left') - || (facility.side === 'right' && dirName === 'right') - || (facility.side === 'top' && dirName === 'up') - || (facility.side === 'bottom' && dirName === 'down'); - } - - function movementOptionFromDir(cell, dirName) { - const d = dirByName(dirName); - if (!d) return null; - const next = { col: cell.col + d.dc, row: cell.row + d.dr }; - if (inGrid(next.col, next.row) && game.conveyorTiles.has(key(next.col, next.row))) { - return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next }; - } - const scanner = scannerReceivingFrom(cell, dirName); - if (scanner) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner }; - const facilityPair = facilityAtEntryCell(cell); - if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) { - return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) }; - } - return null; - } - - function pickMovementOption(chick, cell, dirs) { - const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean); - if (!candidates.length) return null; - const moving = candidates.filter(opt => opt.type !== 'facility'); - const empty = moving.filter(opt => !targetBlockedByChick(chick, opt.target)); - const pool = empty.length ? empty : candidates.filter(opt => opt.type === 'facility'); - if (!pool.length) return null; - return pool[Math.floor(Math.random() * pool.length)]; - } - - function setSingleSegmentRoute(chick, target) { - const route = []; - appendPoint(route, { x: chick.x, y: chick.y }); - appendPoint(route, target); - chick.route = route.length ? route : [{ x: chick.x, y: chick.y }]; - chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length; - } - - function advanceByCurrentConveyor(chick, index) { - const cell = pointToCell(chick.x, chick.y); - if (!cell) return removeChick(index, 'OFF GRID'); - const currentKey = key(cell.col, cell.row); - if (!game.conveyorTiles.has(currentKey)) return removeChick(index, 'OFF BELT'); - const dirs = conveyorOutDirNames(currentKey); - if (!dirs.length) { - chick.stoppedTimer = 0.25; - return; - } - const option = pickMovementOption(chick, cell, dirs); - if (!option) { - chick.stoppedTimer = 0.25; - return; - } - if (option.type === 'facility') { - if (option.facilityId === 'mixer') resolveMixer(index); - else if (option.facilityId === 'truck') resolveTruck(index); - else if (option.facilityId === 'trash') resolveTrash(index); - else removeChick(index, 'DONE'); - return; - } - if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id; - setSingleSegmentRoute(chick, option.target); + function routeProgress(chick) { + return chick.targetIndex || 0; } function conveyorKeyFromPoint(p) { @@ -381,11 +284,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function sortSlot(slot, side) { if (game.phase !== 'running') return; const scanner = scannerBySlot(game, slot); - sortScanner(scanner, side); - } - - function sortScanner(scanner, side) { - if (game.phase !== 'running' || !scanner || scanner.kind !== 'manual') return; + if (!scanner) return; scanner.keyPressTime = 0.18; scanner.keyPressSide = side; if (!scanner.queue.length) return; @@ -393,100 +292,25 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck if (index >= 0) sortChickByIndex(index, side, false); } - function manualScanSucceeded(scanner, chick, side) { - return scanner.kind === 'manual' && autoSideFor(scanner, chick) === side; - } - - function resetManualCombo(scanner) { - const previous = game.manualCombo?.count || 0; - if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 }; - game.manualCombo.count = 0; - game.manualCombo.lastBonus = 0; - game.stats.manualComboFailure = (game.stats.manualComboFailure || 0) + 1; - game.totals.manualComboFailure = (game.totals.manualComboFailure || 0) + 1; - if (previous >= 10) { - const c = scannerCenter(scanner); - shake(game, 10, 0.25); - floating(game, c.x, c.y - 36, 'COMBO RESET', THEME.danger); - } - } - - function awardManualCombo(scanner) { - if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 }; - game.manualCombo.count = Math.max(0, game.manualCombo.count || 0) + 1; - game.stats.manualComboSuccess = (game.stats.manualComboSuccess || 0) + 1; - game.totals.manualComboSuccess = (game.totals.manualComboSuccess || 0) + 1; - const combo = game.manualCombo.count; - game.manualCombo.lastBonus = 0; - if (combo < 10) return 0; - const trials = Math.max(1, Math.floor(combo / 10)); - const manualScannerCount = game.scanners.filter(s => s.kind === 'manual').length; - const chance = Math.min(1, (20 + manualScannerCount * 10) / 100); - let successfulTrials = 0; - for (let i = 0; i < trials; i += 1) { - if (Math.random() < chance) successfulTrials += 1; - } - const bonus = Math.floor(successfulTrials * Math.max(1, game.turn || 1) * 5 / 4); - if (bonus > 0) { - applyRevenue(game, bonus); - game.stats.manualComboBonus = (game.stats.manualComboBonus || 0) + bonus; - game.totals.manualComboBonus = (game.totals.manualComboBonus || 0) + bonus; - game.manualCombo.lastBonus = bonus; - const c = scannerCenter(scanner); - floating(game, c.x, c.y - 42, `COMBO +${yen(bonus)}`, THEME.green); - } - return bonus; - } - - function scannerOutputStartCell(scanner, side) { - const connector = scannerConnector(scanner, side); - if (!connector) return null; - const connectorKey = key(connector.col, connector.row); - if (game.conveyorTiles.has(connectorKey)) return { ...connector }; - const graph = ensureFactoryGraph(game); - const keys = (graph.scannerPorts.get(scanner.id)?.[side]?.keys || []).filter(Boolean); - if (!keys.length) return null; - const parsed = keys.map(parseKey); - const exact = parsed.find(p => p.col === connector.col && p.row === connector.row); - return exact || parsed[0] || null; - } - - function scannerReleaseRoute(chick, scanner, side, startCell) { - const route = []; - appendPoint(route, { x: chick.x, y: chick.y }); - const connector = scannerConnector(scanner, side); - if (connector) appendPoint(route, cellCenter(connector.col, connector.row)); - appendPoint(route, cellCenter(startCell.col, startCell.row)); - return route; - } - function sortChickByIndex(index, side, auto) { const chick = game.chicks[index]; if (!chick || chick.stage !== 'queued') return false; const scanner = scannerById(game, chick.scannerId); if (!scanner) return false; - const startCell = scannerOutputStartCell(scanner, side); - if (!startCell) { - if (!auto && scanner.kind === 'manual') resetManualCombo(scanner); + const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true); + if (!plan) { floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); return false; } - if (!auto && scanner.kind === 'manual') { - if (manualScanSucceeded(scanner, chick, side)) awardManualCombo(scanner); - else resetManualCombo(scanner); - } scanner.queue = scanner.queue.filter(id => id !== chick.id); - chick.stage = 'belt'; - chick.route = scannerReleaseRoute(chick, scanner, side, startCell); - chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length; - chick.scannerId = null; - chick.nextScannerId = null; - chick.pendingScannerId = null; + chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`; + chick.route = plan.route; + chick.targetIndex = 1; + chick.nextScannerId = plan.nextScannerId || null; chick.queueRoute = null; chick.queueIndex = -1; - const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink); - scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor); - floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor); + scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination)); + floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination)); return true; } @@ -678,12 +502,25 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger); } + function explodeRoute(route, extras, label) { + const victims = game.chicks.filter(ch => route.some(p => Math.hypot(ch.x - p.x, ch.y - p.y) < GRID.cell)); + game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id)); + for (const v of victims) flyingDebris(game, v.sex, v.x, v.y); + for (const e of extras) flyingDebris(game, e.sex, e.x, e.y); + for (const p of route) { + shockwave(game, p.x, p.y, THEME.danger); + sparkBurst(game, p.x, p.y, 4); + smokeBurst(game, p.x, p.y, 3); + } + shake(game, 18, .75); + if (route[0]) floating(game, route[0].x, route[0].y - 18, label, THEME.danger); + } + return { activeQueuedChick, currentSpawnDelay, sortSlot, updateCongestion, - sortScanner, updateRunning }; } diff --git a/src/systems/contracts.js b/src/systems/contracts.js index 2920238..8f208d4 100644 --- a/src/systems/contracts.js +++ b/src/systems/contracts.js @@ -2,7 +2,7 @@ import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TU import { getSpawnRange } from '../core/state.js'; import { applyRevenue } from './economy.js'; -const CONTRACT_TARGETS = [ +export const CONTRACT_TARGETS = [ { target: 'female', title: 'Premium Female Shipment', @@ -107,6 +107,10 @@ export function isTargetTruckCargo(game, sex) { return truckTarget(game) === sex; } +export function isPoopSoilActive(game) { + return truckTarget(game) !== 'poop'; +} + export function shouldFineMaleTruck(game) { return truckTarget(game) !== 'male'; } diff --git a/src/systems/economy.js b/src/systems/economy.js index 97d9484..d79f639 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -82,6 +82,10 @@ export function shredderBonusChance(gameOrCount) { return Math.min(1, percent / 100); } +export function shredderBonusExpectedPerItem(gameOrCount) { + return shredderBonusChance(gameOrCount); +} + export function rollShredderBonus(game) { const cards = shredderUpgradeCount(game); const chance = shredderBonusChance(cards); @@ -113,6 +117,10 @@ export function zundaTaxInfo(cash, game = null) { return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork }; } +export function zundaTaxForCash(cash, game = null) { + return zundaTaxInfo(cash, game).tax; +} + export function fairiesTributeInfo(game, turn = game.turn) { const day = Math.max(1, Math.floor(Number(turn) || 1)); const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay); @@ -138,7 +146,18 @@ export function explosionDamageForPrice(price, game = null) { return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game))); } -function positivePayout(_game, amount) { +export function upgradeCostFor(obj) { + if (!obj) return null; + if (obj.type === 'eggFarm') return FACILITY_DEFS.eggFarm.upgradeCosts[obj.level] || null; + if (obj.type === 'facility' && INCOME_FACILITY_IDS.includes(obj.id)) { + const level = obj.level || 1; + if (level <= 1) return 300; + return Math.ceil(900 * Math.pow(1.65, level - 2)); + } + return null; +} + +export function positivePayout(_game, amount) { return Math.max(0, Math.ceil(amount)); } @@ -224,21 +243,16 @@ export function settleTruckRevenue(game) { const adjusted = positivePayout(game, base); game.stats.pendingTruckRevenue = adjusted; game.stats.truckRevenue = adjusted; - if (target === 'poop') game.stats.poopShipmentIncome = adjusted; - else game.stats.chickShipmentIncome = adjusted; if (adjusted > 0) applyRevenue(game, adjusted); return { base, adjusted, target, targetCount, unitPrice }; } -export function collectZundaTax(game, basisProfit = 0) { - const info = zundaTaxInfo(basisProfit, game); +export function collectZundaTax(game, basisCash = game.cash) { + const info = zundaTaxInfo(basisCash, game); if (info.tax <= 0) return info; - // ZUNDA TAX is settled between days. It reduces cash and lifetime totals, - // but it is intentionally excluded from the next day's NET display. - game.totals.zundaTax = (game.totals.zundaTax || 0) + info.tax; - game.totals.penalty = (game.totals.penalty || 0) + info.tax; - game.totals.profit = (game.totals.profit || 0) - info.tax; - game.cash -= info.tax; + game.stats.zundaTax += info.tax; + game.totals.zundaTax += info.tax; + applyPenalty(game, info.tax); return info; } @@ -255,6 +269,21 @@ export function collectFairiesTribute(game, turn = game.turn) { return info; } +export function applyRescueLoanIfNeeded(game) { + if (!game?.cardEffects || game.cash >= 0) return { amount: 0, used: 0, remaining: Math.max(0, game?.cardEffects?.rescueLoanCharges || 0) }; + let used = 0; + let amount = 0; + while (game.cash < 0 && (game.cardEffects.rescueLoanCharges || 0) > 0) { + game.cardEffects.rescueLoanCharges -= 1; + used += 1; + amount += 500; + game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500; + game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500; + applyRevenue(game, 500); + } + return { amount, used, remaining: Math.max(0, game.cardEffects.rescueLoanCharges || 0) }; +} + export function collectChemicalWeaponSubsidy(game) { const cards = effectCount(game, 'chemicalWeaponSubsidy'); const poopTruck = Math.max(0, game?.stats?.poopTruck || 0); @@ -308,3 +337,6 @@ export function finalScore(game) { )); return { score, factoryValue: value, base, dailyEarned, dayPenalty, correctBonus, explosionPenalty, days }; } + +export const MIXER_PRICE = ECONOMY.income.mixer; +export const TRUCK_PRICE = ECONOMY.income.truck; diff --git a/src/systems/gameEvents.js b/src/systems/gameEvents.js new file mode 100644 index 0000000..65dd54e --- /dev/null +++ b/src/systems/gameEvents.js @@ -0,0 +1,39 @@ +const MAX_EVENTS = 160; + +export function emitGameEvent(game, type, payload = {}) { + if (!game) return null; + if (!Array.isArray(game.eventLog)) game.eventLog = []; + const id = game.nextEventId || 1; + game.nextEventId = id + 1; + const event = { + id, + at: Date.now(), + day: game.turn || 1, + phase: game.phase || 'unknown', + type, + payload: sanitizePayload(payload) + }; + game.eventLog.push(event); + if (game.eventLog.length > MAX_EVENTS) game.eventLog.splice(0, game.eventLog.length - MAX_EVENTS); + return event; +} + +export function recentGameEvents(game, limit = 8) { + const events = Array.isArray(game?.eventLog) ? game.eventLog : []; + return events.slice(Math.max(0, events.length - limit)).reverse(); +} + +export function clearGameEvents(game) { + if (game) game.eventLog = []; +} + +function sanitizePayload(payload) { + if (!payload || typeof payload !== 'object') return payload; + const out = {}; + for (const [k, v] of Object.entries(payload)) { + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean' || v == null) out[k] = v; + else if (Array.isArray(v)) out[k] = v.slice(0, 12).map(x => typeof x === 'object' ? JSON.stringify(x).slice(0, 80) : x); + else out[k] = JSON.stringify(v).slice(0, 120); + } + return out; +} diff --git a/src/systems/history.js b/src/systems/history.js index e121ae8..d71d435 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -1,7 +1,5 @@ -import { BALANCE } from '../core/balance.js'; -import { GRID } from '../core/config.js'; export function cleanScanner(scanner) { - return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 }; + return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 }; } export function snapshot(game) { return JSON.stringify({ @@ -9,16 +7,6 @@ export function snapshot(game) { stats: game.stats, totals: game.totals, nextId: game.nextId, - gridX: GRID.x, - gridY: GRID.y, - gridCols: game.gridCols || GRID.cols, - gridRows: game.gridRows || GRID.rows, - gridExpansionPurchases: game.gridExpansionPurchases || 0, - gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { right: 0, up: 0 }, - gridChunkOriginY: game.gridChunkOriginY || 0, - ownedCells: [...(game.ownedCells || [])], - ownedChunks: [...(game.ownedChunks || [])], - blockedCells: [...(game.blockedCells || [])], selected: game.selected, multiSelected: game.multiSelected || [], buildTool: game.buildTool, @@ -40,24 +28,10 @@ export function restore(game, text) { game.stats = data.stats; game.totals = data.totals; game.nextId = data.nextId; - GRID.x = data.gridX ?? BALANCE.grid.x; - GRID.y = data.gridY ?? BALANCE.grid.y; - GRID.cols = data.gridCols || BALANCE.grid.cols; - GRID.rows = data.gridRows || BALANCE.grid.rows; - game.gridCols = GRID.cols; - game.gridRows = GRID.rows; - game.gridExpansionPurchases = data.gridExpansionPurchases || 0; - game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { right: 0, up: 0 }; - game.gridChunkOriginY = data.gridChunkOriginY || 0; - game.ownedCells = new Set(data.ownedCells || []); - game.ownedChunks = new Set(data.ownedChunks || []); - game.gridOwnedCells = game.ownedCells.size || (GRID.cols * GRID.rows); - game.blockedCells = new Set(data.blockedCells || []); game.selected = data.selected; game.multiSelected = data.multiSelected || []; game.buildTool = data.buildTool; - game.view = data.view || { x: 0, y: 0, scale: 1 }; - if (game.view.scale == null) game.view.scale = 1; + game.view = data.view || { x: 0, y: 0 }; game.facilities = data.facilities || {}; game.eggFarms = data.eggFarms || []; game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 })); diff --git a/src/systems/maintenance.js b/src/systems/maintenance.js index a5bec62..32ebbf2 100644 --- a/src/systems/maintenance.js +++ b/src/systems/maintenance.js @@ -101,6 +101,10 @@ export function performanceFactor(target) { return Math.max(RULES.minimumPerformance, 1 - t * (1 - RULES.minimumPerformance)); } +export function degradationPercent(target) { + return Math.round(wearRatio(target) * 100); +} + export function conveyorTarget(game, conveyorKey) { const meta = game.conveyorMeta?.get(conveyorKey); return meta ? { type: 'conveyor', key: conveyorKey, meta } : null; @@ -217,8 +221,6 @@ export function hireRepairmanForNextDay(game) { const cost = repairmanDailyCost(game); if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` }; spendCash(game, cost); - game.pendingRepairWorkerWages = (game.pendingRepairWorkerWages || 0) + cost; - game.totals.repairWorkerWages = (game.totals.repairWorkerWages || 0) + cost; game.repairman.hiredForNextDay = true; return { ok: true, cost }; } @@ -283,7 +285,7 @@ export function updateRepairman(game, dt) { if (targetWear(target) <= 0.001) r.target = null; } -function worstDegradation(game) { +export function worstDegradation(game) { let worst = 0; let label = 'none'; for (const t of equipmentMaintenanceTargets(game)) { diff --git a/src/systems/routing.js b/src/systems/routing.js index 3bad2fc..08c8289 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -29,6 +29,14 @@ function adjacentCells(point) { 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 }] : []; +} + function visualPortConveyorCells(game, point, blocked = [], preferred = []) { if (!point || !inGrid(point.col, point.row)) return []; const result = []; @@ -49,6 +57,21 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) { return exact.length ? exact : cells; } +// Kept for explicit non-port use. +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 // ----------------------------------------------------------------------------- @@ -72,11 +95,34 @@ export function facilityEntryPoint(game, dest) { 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 visualPortConveyorCells(game, f.entry); +} + +export function portHasConveyor(game, port) { + return visualPortConveyorCells(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; @@ -94,14 +140,6 @@ export function refreshRoutingAfterEdit(game) { function graphNodeKey(col, row) { return key(col, row); } function graphCell(k) { return parseKey(k); } -function conveyorOutDirNames(game, cellKey) { - const meta = game.conveyorMeta?.get?.(cellKey) || {}; - const dirs = []; - if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs); - if (meta.dir) dirs.push(meta.dir); - return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir)); -} - function emptyFactoryGraph(game) { return { version: game.routingVersion || 0, @@ -140,9 +178,7 @@ export function buildFactoryGraph(game) { for (const k of graph.cells) { const p = graphCell(k); - const explicitDirs = conveyorOutDirNames(game, k); - const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : DIRS; - for (const d of dirs) { + for (const d of DIRS) { const nk = graphNodeKey(p.col + d.dc, p.row + d.dr); if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name }); } @@ -235,7 +271,7 @@ export function commitFactoryGraphForDay(game) { return graph; } -function graphSummary(graph) { +export function graphSummary(graph) { if (!graph) return null; return { version: graph.version, @@ -260,6 +296,11 @@ export function getConveyorNeighbors(game, col, row) { return (graph.adjacency.get(k) || []).map(n => ({ ...graphCell(n.key), dir: DIRS.find(d => d.name === n.dir) || { name: n.dir, dc: 0, dr: 0 } })); } +export function getAdjacentConveyors(game, col, row) { + const graph = ensureFactoryGraph(game); + return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row))); +} + function dirBetween(a, b) { const dc = b.col - a.col, dr = b.row - a.row; return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null; @@ -280,68 +321,38 @@ export function bfsAllRoutes(game, start, isGoal) { if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p }))); const startState = `${startK}|none`; - const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none', depth: 0 }]; - const depthByState = new Map([[startState, 0]]); - const parents = new Map(); + const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }]; + const visited = new Set([startState]); + const parent = new Map(); const found = []; - let bestGoalDepth = Infinity; - const maxParentsPerState = 8; - const maxGoalStates = 24; - const maxPaths = 80; - while (queue.length) { const cur = queue.shift(); - if (cur.depth > bestGoalDepth) break; const curStateK = `${cur.key}|${cur.incoming}`; - if (isGoal(cur)) { - found.push(curStateK); - bestGoalDepth = cur.depth; - if (found.length >= maxGoalStates) break; - continue; - } + if (isGoal(cur)) found.push(curStateK); for (const n of graph.adjacency.get(cur.key) || []) { const next = graphCell(n.key); const outDir = dirBetween(cur, next); if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue; - const nextDepth = cur.depth + 1; - if (nextDepth > bestGoalDepth) continue; const nextStateK = `${n.key}|${outDir}`; - const knownDepth = depthByState.get(nextStateK); - if (knownDepth == null) { - depthByState.set(nextStateK, nextDepth); - parents.set(nextStateK, [curStateK]); - queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir, depth: nextDepth }); - } else if (knownDepth === nextDepth) { - const list = parents.get(nextStateK) || []; - if (list.length < maxParentsPerState && !list.includes(curStateK)) list.push(curStateK); - parents.set(nextStateK, list); - } + if (visited.has(nextStateK)) continue; + visited.add(nextStateK); + parent.set(nextStateK, curStateK); + queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir }); } } - - const paths = []; - const expand = (state, suffix) => { - if (paths.length >= maxPaths) return; - const [cellK] = state.split('|'); - const nextSuffix = [graphCell(cellK), ...suffix]; - if (state === startState || cellK === startK) { - paths.push(nextSuffix); - return; + const paths = found.map(goalState => { + const reversed = []; + let cursor = goalState; + while (cursor) { + const [cellK] = cursor.split('|'); + reversed.push(graphCell(cellK)); + if (cellK === startK) break; + cursor = parent.get(cursor); } - for (const parent of parents.get(state) || []) expand(parent, nextSuffix); - }; - for (const goalState of found) expand(goalState, []); - - const unique = []; - const seen = new Set(); - for (const path of paths) { - const sig = path.map(p => graphNodeKey(p.col, p.row)).join('>'); - if (seen.has(sig)) continue; - seen.add(sig); - unique.push(path); - } - if (isGoal.cacheKey) graph.pathCache.set(cacheKey, unique.map(path => path.map(p => ({ ...p })))); - return unique; + return reversed.reverse(); + }); + if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p })))); + return paths; } export function buildConveyorComponents(game) { @@ -403,25 +414,6 @@ function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) { return points; } -function chickCellOccupancyScore(game, cell, depth = 0) { - const c = cellCenter(cell.col, cell.row); - let score = 0; - for (const chick of game.chicks || []) { - if (chick.stage === 'flying' || chick.stage === 'queued') continue; - const d = Math.hypot(chick.x - c.x, chick.y - c.y); - if (d < GRID.cell * 0.50) score += depth <= 1 ? 100 : Math.max(1, 12 - depth); - } - return score; -} - -function candidateCongestionScore(game, candidate) { - const cells = candidate.cells || []; - let score = 0; - const limit = Math.min(cells.length, 8); - for (let i = 0; i < limit; i += 1) score += chickCellOccupancyScore(game, cells[i], i); - return score; -} - export function chooseRoundRobin(game, id, candidates, advance = false) { if (!candidates.length) return null; const sorted = [...candidates].sort((a, b) => { @@ -435,14 +427,9 @@ export function chooseRoundRobin(game, id, candidates, advance = false) { const kb = b.key || JSON.stringify(b.route || b.cells || b); return ka.localeCompare(kb); }); - if (!advance || sorted.length === 1) return sorted[0]; - const bestBias = sorted[0].roleBias ?? 0; - let pool = sorted.filter(item => (item.roleBias ?? 0) === bestBias); - const bestScore = Math.min(...pool.map(item => candidateCongestionScore(game, item))); - pool = pool.filter(item => candidateCongestionScore(game, item) === bestScore); const n = game.branchCounters.get(id) || 0; - game.branchCounters.set(id, n + 1); - return pool[Math.floor(Math.random() * pool.length)]; + if (advance) game.branchCounters.set(id, n + 1); + return sorted[n % sorted.length]; } export function scannerRules(scanner) { @@ -466,6 +453,13 @@ 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 graph = ensureFactoryGraph(game); + const port = graph.facilityPorts.get(dest); + return !!port?.connected && (port.keys || [port.key]).includes(graphNodeKey(p.col, p.row)); +} + export function routeFromFarmToScanner(game, farm, advance = false) { const graph = ensureFactoryGraph(game); const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell); @@ -558,44 +552,6 @@ function outputRouteReachesFacility(game, scanner, visited = new Set()) { return false; } -function scannerOutputReachesDestination(game, scanner, dest, visited = new Set()) { - if (!scanner || visited.has(scanner.id)) return false; - visited.add(scanner.id); - for (const side of ['left', 'right']) { - const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false); - if (!plan) continue; - if (plan.destination === dest) return true; - if (plan.destination === 'scanner') { - const next = scannerById(game, plan.nextScannerId); - if (scannerOutputReachesDestination(game, next, dest, visited)) return true; - } - } - return false; -} - -export function eggFarmHasValidRoute(game, farm) { - return !!routeFromFarmToScanner(game, farm, false)?.scannerId; -} - -export function facilityHasValidRoute(game, dest) { - const graph = ensureFactoryGraph(game); - if (!graph.facilityPorts.get(dest)?.connected) return false; - return game.scanners.some(scanner => scannerOutputReachesDestination(game, scanner, dest)); -} - -export function disconnectedBuildWarnings(game) { - if (game.phase !== 'build') return []; - const warnings = []; - for (const farm of game.eggFarms || []) { - if (!eggFarmHasValidRoute(game, farm)) warnings.push({ type: 'eggFarm', id: farm.id, ref: farm, message: 'This Egg will not produce chicks' }); - } - for (const id of MACHINE_FACILITY_IDS) { - const facility = game.facilities?.[id]; - if (facility && !facilityHasValidRoute(game, id)) warnings.push({ type: 'facility', id, ref: facility, message: 'This facility cannot receive items' }); - } - return warnings; -} - export function minimumStartConnectionIssues(game) { const graph = ensureFactoryGraph(game); for (const farm of game.eggFarms || []) { diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index e5bc635..02348e5 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -159,7 +159,6 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme if (origin.type !== 'facility') continue; const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; const { entry, side } = nearestGridEdge(nextCenter); - if (!pointInGrid(entry.col, entry.row)) return true; if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true; const draft = { ...origin.obj, entry: { ...entry }, side }; layoutFacilityOnEdge(draft, entry, side); diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index 639aeda..ee6bfe6 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -22,7 +22,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act hasBuildableOption = true; if (game.cash >= buildPrice(id, game)) return false; } - if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false; return hasBuildableOption; } @@ -46,30 +45,18 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act const priceSpan = btn.querySelector('span'); if (priceSpan) priceSpan.textContent = id === 'conveyor' ? `${yen(price)} / tile` : yen(price); const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id]; - const manualLimitReached = id === 'manualScanner' && game.scanners.filter(s => s.kind === 'manual').length >= 8; const unaffordable = game.cash < price; - btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt || manualLimitReached; + btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt; if (btn.disabled && game.buildTool === id) game.buildTool = null; btn.classList.toggle('unaffordable', unaffordable); - btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached); + btn.classList.toggle('already-built', uniqueAlreadyBuilt); btn.title = unaffordable ? `Need ${yen(price - game.cash)} more` - : manualLimitReached - ? 'Manual Scanner limit reached (8 max)' - : uniqueAlreadyBuilt + : uniqueAlreadyBuilt ? `${FACILITY_DEFS[id]?.name || id} already exists` : ''; } if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; - if (ui.expandGridButton && build?.gridExpansionCost) { - const cost = build.gridExpansionCost(); - const priceSpan = ui.expandGridButton.querySelector('span'); - if (priceSpan) priceSpan.textContent = `${yen(cost)} / +10×10`; - const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || game.cash < cost; - ui.expandGridButton.disabled = blocked; - ui.expandGridButton.classList.toggle('unaffordable', game.cash < cost); - ui.expandGridButton.title = game.cash < cost ? `Need ${yen(cost - game.cash)} more` : `Add the next right 10×10 lot, or click an upper/right lot button on the canvas. Next purchase count: ${(game.gridExpansionPurchases || 0) + 1}`; - } if (ui.hireRepairmanButton) { const hired = !!game.repairman?.hiredForNextDay; const cost = repairmanDailyCost(game); @@ -77,7 +64,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act ui.hireRepairmanButton.disabled = blocked; ui.hireRepairmanButton.classList.toggle('active', hired); ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost); - ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 2% durability per second.`; + ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 3% durability per second.`; } const cashout = noAffordableBuildTools(); ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout); @@ -103,22 +90,21 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act function updateTurnSummary() { const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length; const issues = facilityConnectionIssues(game); - const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)); - const tax = zundaTaxInfo(zundaBasisProfit, game); + const tax = zundaTaxInfo(game.cash, game); const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1); const tribute = fairiesTributeInfo(game, tributeDay); const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null; const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0; ui.turnSummary.innerHTML = [ - `Seed: ${escapeHtml(game.runSeed || 'none')} | Grid box: ${game.gridRows || 10}×${game.gridCols || 20} | Owned: ${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))} cells | Blocked: ${game.blockedCells?.size || 0} cells`, + `Seed: ${escapeHtml(game.runSeed || 'none')} | Blocked: ${game.blockedCells?.size || 0} cells`, `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, - `ZUNDA TAX on Next Day: -${yen(tax.tax)} | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, + `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, lastTribute != null ? `Fairies tribute paid before Build: -${yen(lastTribute)}${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}` : `Fairies tribute before Build: -${yen(tribute.amount)}${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`, `Maintenance: worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label}) | Repairman: ${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}`, - `Manual combo: ${game.manualCombo?.count || 0} | Next expansion: ${yen(build?.gridExpansionCost ? build.gridExpansionCost() : 0)} | Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms with scanner route: ${connected}/${game.eggFarms.length}`, + `Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms with scanner route: ${connected}/${game.eggFarms.length}`, game.cardEffects?.usedMachineActive ? 'Procurement: USED MACHINE MODE / no refunds / 60% durability' : '', issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); @@ -157,7 +143,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act ui.shell.classList.toggle('phase-build', game.phase === 'build'); ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover'); ui.shell.classList.toggle('phase-title', game.phase === 'title'); - ui.shell.classList.toggle('phase-loanDecision', game.phase === 'loanDecision'); ui.money.textContent = yen(game.cash); ui.money.classList.toggle('cash-negative', game.cash < 0); ui.money.classList.toggle('cash-positive', game.cash > STARTING_CASH); @@ -167,7 +152,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act : `${Math.max(0, game.timeLeft).toFixed(1)}s`; ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0))); ui.phase.textContent = phaseLabel(); - if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0; updatePriorityStrip(); ui.turnProfit.textContent = yen(game.stats.profit); ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0); @@ -180,17 +164,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act 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 s1 = game.scanners.find(s => s.kind === 'manual' && s.slot === 0); - const s2 = game.scanners.find(s => s.kind === 'manual' && s.slot === 1); - if (s1) { - ui.buttons.s1Left.textContent = `S1 ${s1.keys?.left?.label || 'A'} -> Mixer`; - ui.buttons.s1Right.textContent = `S1 ${s1.keys?.right?.label || 'D'} -> S2`; - } - if (s2) { - ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`; - ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`; - } - const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), game).tax; + const nextTax = zundaTaxInfo(game.cash, game).tax; ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay; ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game); } @@ -199,7 +173,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return; const target = truckTarget(game).toUpperCase(); ui.targetBrief.textContent = `TRUCK TARGET: ${target}`; - ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`; + ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`; ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; const active = game.contractActive; const offer = game.contractOffer; @@ -220,7 +194,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act function phaseLabel() { if (game.phase === 'running') return game.timeLeft <= 0 ? TEXT.phases.clearing : TEXT.phases.running; - if (game.phase === 'loanDecision') return TEXT.phases.loanDecision; if (game.phase === 'build') return TEXT.phases.build; if (game.phase === 'gameover') return TEXT.phases.gameover; return TEXT.phases.title; @@ -236,50 +209,16 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act } function showTurnResult(r) { - const positive = value => `${value > 0 ? '+' : ''}${yen(value)}`; - const negative = value => `${value > 0 ? '-' : ''}${yen(value)}`; - const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative'; - const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0); ui.modalTitle.textContent = `Day ${r.turn} Settlement`; ui.modalBody.innerHTML = ` -
Net result${yen(r.profit)}
-
-
Chick shipment income${positive(r.chickShipmentIncome || 0)}
-
Poop shipment income${positive(r.poopShipmentIncome || 0)}
-
Mixer income${positive(r.mixerRevenue || 0)}
-
Manual combo bonus${positive(r.manualComboBonus || 0)}
-
Chemical subsidy${positive(r.chemicalWeaponSubsidy || 0)}
-
Contract bonus${positive(r.contractBonus || 0)}
-
Penalties${negative(penalties)}
-
Fairies fee${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}
-
Repair worker wages${negative(r.repairWorkerWages || 0)}
-
Loan repayments${negative(r.loanRepayment || 0)}
-
-

Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = ${yen(r.profit)}

`; +

${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}

+

Final profit: ${yen(r.profit)}

`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button')); ui.modal.classList.remove('equipment-popover', 'gameover-modal'); ui.modal.classList.add('visible'); } - function showEmergencyLoanDecision(onUse, onDecline) { - const charges = Math.max(0, game.cardEffects?.rescueLoanCharges || 0); - ui.modalTitle.textContent = 'Emergency Loan Available'; - ui.modalBody.innerHTML = ` -

End-of-day cash is below ${yen(0)}.

-
-
Current money${yen(game.cash)}
-
Emergency Loan+${yen(500)}
-
Cards remaining${charges}
-
-

Use one Emergency Loan to gain ${yen(500)} immediately. If cash is still negative, another decision will be shown if another card remains.

`; - ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(button('Accept Game Over', onDecline, 'facility-action danger')); - ui.modalActions.appendChild(button('Use Emergency Loan', onUse, 'primary-button')); - ui.modal.classList.remove('equipment-popover', 'gameover-modal'); - ui.modal.classList.add('visible'); - } - function showGameOver() { const processed = game.totals.processed; @@ -317,5 +256,5 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act // Bankruptcy is intentionally checked only in completeTurn(), after all day-end revenue, tribute, and rescue-loan effects are settled. } - return { updatePanels, updateUI, showTitle, showTurnResult, showEmergencyLoanDecision, showGameOver, hideModal, checkGameOver }; + return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver }; } diff --git a/styles.css b/styles.css index 859a54f..dae2754 100644 --- a/styles.css +++ b/styles.css @@ -20,7 +20,7 @@ h1, h2, p { margin: 0; } .game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); } #gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; } .hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; } -.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(6, minmax(82px, auto)); } +.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); } .hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); } .hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); } .hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); } @@ -29,7 +29,7 @@ h1, h2, p { margin: 0; } .hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); } .hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; } .hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); } -.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo strong, .cash-positive { color: var(--green) !important; } +.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; } .cash-negative { color: var(--danger) !important; } .priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; } @@ -66,76 +66,6 @@ h1, h2, p { margin: 0; } .facility-action.danger { background: #ffdada; } .control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); } .sort-button { min-height: 46px; font-size: 12px; } -.debug-panel { - position: absolute; - z-index: 9; - left: 10px; - bottom: 10px; - width: min(292px, calc(100% - 20px)); - border: 3px solid var(--line); - background: rgba(255,255,255,.94); - box-shadow: 4px 4px 0 rgba(16,32,21,.16); - font-weight: 900; -} -.debug-panel summary { - cursor: pointer; - padding: 4px 8px; - color: var(--danger); - font-size: 14px; - line-height: 1.1; -} -.debug-body { - display: grid; - grid-template-columns: 1fr 74px; - gap: 5px; - padding: 6px; - border-top: 3px solid var(--line); - font-size: 13px; - line-height: 1.15; -} -.debug-body label, -.debug-body button, -.debug-body select, -.debug-body input { - min-width: 0; - font: inherit; -} -.debug-check, -.debug-card { - grid-column: 1 / -1; -} -.debug-day { - display: grid; - grid-template-columns: 34px 1fr; - align-items: center; - gap: 4px; -} -.debug-body button { - border: 2px solid var(--line); - background: var(--white); - cursor: pointer; - font-weight: 900; - padding: 4px 5px; - line-height: 1.05; -} -.debug-body input[type="number"], -.debug-body select { - width: 100%; - border: 2px solid var(--line); - background: var(--white); - padding: 3px 4px; - min-height: 28px; -} -.debug-readout { - grid-column: 1 / -1; - border: 2px solid var(--line); - background: #f7fff5; - color: var(--muted); - padding: 5px; - line-height: 1.2; - max-height: 48px; - overflow: hidden; -} .modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); } .modal.visible { display: flex; } .modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); } @@ -272,8 +202,6 @@ h1, h2, p { margin: 0; } .card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); } .card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; } .card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; } -.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 7px 0; } -.card-tags span { margin: 0; padding: 2px 5px; font-size: 8px; line-height: 1.1; letter-spacing: .04em; } .card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; } .card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); } .card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); } @@ -317,7 +245,6 @@ body { font-size: 16px; } .hover-tooltip.visible { display: block; } .hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; } .hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; } -.hover-tooltip em.warning { color: var(--danger); } .hover-tooltip span { display: block; color: var(--muted); font-size: 12px; } @@ -407,22 +334,6 @@ body { font-size: 16px; } margin-bottom: 8px; } .gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; } -.settlement-net { - border: 3px solid var(--line); - background: #f7fff5; - padding: 10px 12px; - margin-bottom: 10px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - font-weight: 900; - text-transform: uppercase; -} -.settlement-net strong { font-size: 24px; } -.settlement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } -.settlement-grid div { padding: 8px; } -.settlement-grid span { display: block; font-size: 14px; } .gameover-summary { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; @@ -549,156 +460,3 @@ body { font-size: 18px; } .build-panel { width: auto; } .control-dock { grid-template-columns: repeat(2, 172px); } } - -/* v27.1 expansion/readability pass */ -body { font-size: 20px; } -.panel-head h1 { font-size: 22px; } -.panel-head h1 span { font-size: 13px; } -.panel-head p { font-size: 14px; line-height: 1.35; } -.build-panel h2 { font-size: 15px; } -.priority-strip > div { font-size: 16px; } -.hud-card span { font-size: 13px; } -.hud-card strong { font-size: clamp(22px, 1.8vw, 30px); } -.mini-box, .contract-card { font-size: 15px; line-height: 1.5; } -.compact-rules .mini-box { font-size: 14px; } -.tool-button strong { font-size: 16px; line-height: 1.15; } -.tool-button span { font-size: 13px; line-height: 1.2; } -.tool-button .tool-flavor { - left: auto; - right: 0; - width: min(320px, calc(100vw - 40px)); - font-size: 14px; - line-height: 1.4; - overflow-wrap: anywhere; -} -.tool-button .tool-flavor::before { left: auto; right: 18px; } -.tool-button.unaffordable .tool-flavor, -.tool-button.unaffordable:hover .tool-flavor, -.tool-button:disabled .tool-flavor, -.tool-button:disabled:hover .tool-flavor { display: none; } -.facility-action { font-size: 12px; } -.primary-button { font-size: 17px; } -#nextTurnButton { font-size: 19px; } -.card-choice strong { font-size: 19px; } -.card-choice span { font-size: 12px; } -.card-choice small { font-size: 16px; line-height: 1.45; } -.muted-card-note { font-size: 16px; } -.modal-card p, .modal-card li { font-size: 17px; } -.modal.equipment-popover .modal-card h2 { font-size: 16px; } -.modal.equipment-popover .modal-actions button { font-size: 11px; } -.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; } -.hover-tooltip strong { font-size: 18px; } -.hover-tooltip span { font-size: 15px; } -.sort-button { font-size: 17px; } - -/* v27.3 offscreen scanner monitor / expansion UI pass */ -.scanner-monitor { - position: absolute; - z-index: 8; - left: 12px; - bottom: 92px; - display: none; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 8px; - width: min(560px, calc(100% - 24px)); - pointer-events: auto; -} -.scanner-monitor.visible { display: grid; } -.scanner-monitor-card { - border: 3px solid var(--line); - background: rgba(255,255,255,.95); - box-shadow: 5px 5px 0 rgba(16,32,21,.18); - padding: 9px; - font-weight: 900; -} -.scanner-monitor-card header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 7px; - text-transform: uppercase; -} -.scanner-monitor-card header strong { font-size: 20px; color: var(--green); } -.scanner-monitor-card header span { font-size: 12px; color: var(--muted); } -.scanner-mini-map { - display: grid; - grid-template-columns: 42px 1fr 82px; - align-items: center; - gap: 6px; - margin-bottom: 8px; -} -.scanner-mini-map > span, -.scanner-mini-map > b { - border: 2px solid var(--line); - background: #f7fff5; - padding: 7px 6px; - text-align: center; - font-size: 13px; - min-height: 36px; -} -.scanner-current { display: block; color: var(--ink); } -.scanner-current.male { color: #2477ff; } -.scanner-current.female { color: #d62a85; } -.scanner-current.poop { color: #7b4a23; } -.scanner-current.empty { color: var(--muted); } - -.scanner-mini-grid { - display: grid; - grid-template-columns: repeat(5, 27px); - gap: 3px; - justify-content: center; - align-items: center; - padding: 6px; - margin: 0 0 8px; - border: 2px solid var(--line); - background: #f6f1df; -} -.scanner-mini-grid .mini-cell { - width: 27px; - height: 27px; - display: grid; - place-items: center; - border: 1px solid rgba(22,38,25,.28); - background: #fffdfa; - color: var(--muted); - font-size: 11px; - font-weight: 900; - line-height: 1; -} -.scanner-mini-grid .mini-cell.belt { background: #e7f7e4; color: var(--green-dark); } -.scanner-mini-grid .mini-cell.input { background: #fff3bf; color: var(--ink); font-size: 9px; } -.scanner-mini-grid .mini-cell.scanner { background: var(--green); color: #fff; border-color: var(--line); font-size: 16px; } -.scanner-mini-grid .mini-cell.scanner.male { background: #2477ff; } -.scanner-mini-grid .mini-cell.scanner.female { background: #d62a85; } -.scanner-mini-grid .mini-cell.scanner.poop { background: #7b4a23; } -.scanner-mini-grid .mini-cell.scanner-other { background: #b7f5c6; color: var(--ink); } -.scanner-mini-grid .mini-cell.egg { background: #fff7c4; color: #7a5a00; } -.scanner-mini-grid .mini-cell.port { background: #dbe7ff; color: #27427d; } -.scanner-mini-grid .mini-cell.blocked { background: #d7cfc1; color: #6d4d35; } - -.scanner-monitor-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 7px; -} -.scanner-monitor-actions button { - border: 3px solid var(--line); - background: var(--green-soft); - color: var(--ink); - cursor: pointer; - font: inherit; - font-size: 14px; - font-weight: 900; - min-height: 44px; - box-shadow: 3px 3px 0 rgba(16,32,21,.18); -} -.scanner-monitor-actions button:disabled { - background: #edf3ec; - color: var(--muted); - opacity: .62; - cursor: not-allowed; -} -@media (max-width: 1180px) { - .scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; } -}