From 16bde0b87da6f8413c98865f69a0b6382caf297d Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 8 Jun 2026 18:26:53 +0900 Subject: [PATCH 1/2] hm --- src/README.md | 27 -- src/core/config.js | 7 - src/core/entities.js | 19 +- src/core/state.js | 18 +- src/core/text.js | 11 +- src/game.js | 78 ++++- src/index.html | 106 ------- src/render/draw.js | 55 +++- src/styles.css | 623 ------------------------------------- src/systems/buildSystem.js | 98 +++++- src/systems/cards.js | 55 ++-- src/systems/chickSystem.js | 73 ++++- src/systems/contracts.js | 6 +- src/systems/economy.js | 41 +-- src/systems/gameEvents.js | 39 --- src/systems/history.js | 2 +- src/systems/maintenance.js | 8 +- src/systems/routing.js | 98 +++--- src/systems/uiSystem.js | 65 +++- styles.css | 19 ++ 20 files changed, 460 insertions(+), 988 deletions(-) delete mode 100644 src/README.md delete mode 100644 src/index.html delete mode 100644 src/styles.css delete mode 100644 src/systems/gameEvents.js diff --git a/src/README.md b/src/README.md deleted file mode 100644 index 90ddd52..0000000 --- a/src/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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/config.js b/src/core/config.js index 23bcf64..68e13cf 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -26,14 +26,7 @@ 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 7e9db82..fe24a10 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -2,6 +2,22 @@ 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, @@ -25,7 +41,8 @@ export function createScanner(game, col, row, kind) { queue: [], cooldown: 0, price: cost, builtSession: game.buildSession, - autoMode: 'standard' + autoMode: 'standard', + keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null }; } diff --git a/src/core/state.js b/src/core/state.js index 9e7e66e..ae7550a 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -20,6 +20,12 @@ 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, @@ -62,6 +68,10 @@ export function newTotalStats() { loanRepayment: 0, chemicalWeaponSubsidy: 0, rescueLoan: 0, + manualComboBonus: 0, + manualComboSuccess: 0, + manualComboFailure: 0, + repairWorkerWages: 0, cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, @@ -121,6 +131,8 @@ export function createGame() { 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 + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 }, stats: newTurnStats(), lastResult: null, @@ -175,8 +187,8 @@ const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ ]); const INITIAL_SCANNERS = Object.freeze([ - { 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' } + { 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', 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: 11, row: 8, 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' } } } ]); const INITIAL_EGG_FARMS = Object.freeze([ @@ -245,7 +257,7 @@ export function resetLayout(game) { game.blockedCells = new Set(); installInitialConveyorBackbone(game); game.facilities = defaultFacilities(); - game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] })); + game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, 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 f854201..e387544 100644 --- a/src/core/text.js +++ b/src/core/text.js @@ -1,4 +1,4 @@ -import { AUTO_SCANNER_COOLDOWN, BUILD_TOOL_IDS, FACILITY_DEFS, VERSION } from './config.js'; +import { AUTO_SCANNER_COOLDOWN, FACILITY_DEFS, VERSION } from './config.js'; import { yen } from './utils.js'; export const TEXT = { @@ -10,6 +10,7 @@ export const TEXT = { build: 'Build', running: 'Sorting', clearing: 'Clearing', + loanDecision: 'Emergency Loan', gameover: 'Game Over' }, actions: { @@ -52,10 +53,6 @@ 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 ''; @@ -83,10 +80,6 @@ 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/game.js b/src/game.js index d4a35fa..d5779ed 100644 --- a/src/game.js +++ b/src/game.js @@ -3,7 +3,7 @@ import { buildToolButtonHtml } from './core/text.js'; import { createGame, resetLayout, newTurnStats } from './core/state.js'; import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js'; -import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; +import { applyRevenue, 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'; @@ -108,6 +108,15 @@ function startNextTurn() { game.truckCargo = []; game.view = { x: 0, y: 0 }; game.stats = newTurnStats(); + game.stats.zundaTax = game.lastStartFees?.zundaTax || 0; + game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0; + game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0; + const carriedStartCosts = game.stats.zundaTax + 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; @@ -139,17 +148,33 @@ 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, fairiesTribute: tribute, turn: game.turn, cash: game.cash }; - if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } + 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.phase = 'build'; game.truckCargo = []; game.chicks = []; @@ -174,6 +199,24 @@ function completeTurn() { 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) { @@ -226,8 +269,9 @@ 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}${flavor ? `${flavor}` : ''}${lines.map(line => `${line}`).join('')}`; + ui.hoverTooltip.innerHTML = `${title}${warning ? `${warning}` : (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`; @@ -250,6 +294,8 @@ 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); } canvas.addEventListener('contextmenu', event => event.preventDefault()); @@ -300,7 +346,25 @@ ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); u 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.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(); } }); +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)) 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(); } +}); diff --git a/src/index.html b/src/index.html deleted file mode 100644 index a84c5b6..0000000 --- a/src/index.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - 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 a8cebaa..99c8e73 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -1,6 +1,6 @@ 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 } from '../systems/routing.js'; +import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; import { cardTargetBounds } from '../systems/cards.js'; import { wearRatio } from '../systems/maintenance.js'; @@ -49,6 +49,7 @@ export function drawAll(ctx, canvas, game, helpers) { drawFacilities(ctx, game); drawEggFarms(ctx, game); drawScanners(ctx, game); + drawDisconnectedWarnings(ctx, game); drawChicks(ctx, game, helpers.activeQueuedChick); drawRepairman(ctx, game); drawCardTargetOverlay(ctx, canvas, game); @@ -60,6 +61,41 @@ 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 18px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.strokeText('!', b.cx, b.y + 24); + ctx.fillText('!', b.cx, b.y + 24); + ctx.font = '900 9px 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(); } @@ -282,6 +318,17 @@ 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); @@ -296,7 +343,9 @@ function drawScanner(ctx, scanner, game) { 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 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 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); const q = scanner.queue.length; if (scanner.kind === 'manual') { drawManualKeyboardIcon(ctx, scanner, c); @@ -319,6 +368,8 @@ 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; diff --git a/src/styles.css b/src/styles.css deleted file mode 100644 index 29cafcf..0000000 --- a/src/styles.css +++ /dev/null @@ -1,623 +0,0 @@ -: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 3d5804b..a9ee9d2 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -3,7 +3,7 @@ import { getSpawnRange } from '../core/state.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; import { TEXT, equipmentName } from '../core/text.js'; -import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js'; +import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } 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'; @@ -88,10 +88,6 @@ 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); @@ -144,6 +140,7 @@ 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); @@ -281,11 +278,88 @@ 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); - updatePanels(); + return showManualScannerMenu(obj); } function selectedPopoverWorldPoint(obj) { @@ -318,6 +392,7 @@ 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) { @@ -410,6 +485,13 @@ 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 ''; @@ -432,8 +514,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel updateGroupDrag: selection.updateGroupDrag, finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, - selectedInfoLines, flavorText, - removeSelected, switchScannerRole, + selectedInfoLines, flavorText, disconnectedWarningFor, + removeSelected, switchScannerRole, showManualScannerMenu, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, routeFromFarmToScanner diff --git a/src/systems/cards.js b/src/systems/cards.js index e897fb3..10dbf56 100644 --- a/src/systems/cards.js +++ b/src/systems/cards.js @@ -199,6 +199,34 @@ export const CARD_DEFS = [ } ]; +const CARD_TAGS = { + upgradeEgg: ['EGG', 'ACTIVE'], + upgradeAutoScanner: ['SCANNER', 'ACTIVE'], + upgradeMixer: ['ECONOMY', 'ACTIVE'], + upgradeTruck: ['ECONOMY', 'ACTIVE'], + upgradeTrash: ['POOP', 'ACTIVE'], + crowdedFarming: ['EGG', 'RISK', 'PASSIVE'], + hatchingFeed: ['EGG', 'POOP', 'PASSIVE'], + safetyCover: ['RISK', 'PASSIVE'], + preventiveMaintenance: ['MAINTENANCE', 'PASSIVE'], + durabilityCoating: ['MAINTENANCE', 'PASSIVE'], + bearing: ['CONVEYOR', 'PASSIVE'], + extraEggOutlet: ['EGG', 'RARE', 'PASSIVE'], + recyclingSubsidy: ['ECONOMY', 'RARE', 'PASSIVE'], + dynamite: ['RISK', 'RARE', 'ONE-SHOT'], + usedMachine: ['ECONOMY', 'RISK', 'RARE'], + newMachine: ['ECONOMY', 'ONE-SHOT'], + flattery: ['ECONOMY', 'RARE', 'ONE-SHOT'], + loan: ['ECONOMY', 'RISK', 'RARE'], + extraCards: ['RARE', 'ACTIVE'], + legalWork: ['ECONOMY', 'RARE', 'PASSIVE'], + chemicalWeaponSubsidy: ['POOP', 'ULTRA RARE', 'PASSIVE'], + laborExploitation: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'], + rescueLoan: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT'] +}; + +for (const card of CARD_DEFS) card.tags = CARD_TAGS[card.id] || []; + function effectCount(game, id) { const effects = ensureCardState(game); return Math.max(0, Number(effects[id]) || 0); @@ -226,10 +254,6 @@ export function eggProductionDelayMultiplier(game) { return crowded * feed; } -export function poopRateCardMultiplier(game) { - return Math.pow(1.03, effectCount(game, 'hatchingFeed')); -} - export function extraEggOutletCount(game) { return Math.min(3, effectCount(game, 'extraEggOutlet')); } @@ -247,13 +271,6 @@ 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); @@ -480,13 +497,7 @@ function applyInstantCard(game, card) { if (card.id === 'laborExploitation') effects.laborExploitation = 1; if (card.id === 'rescueLoan') { inc('rescueLoanCharges'); - 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); - } + 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)); @@ -497,11 +508,6 @@ 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)}%`; } @@ -732,7 +738,8 @@ 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')); - b.innerHTML = `${card.title}${rarity}${cardDescription(game, card)}`; + 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.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 5849327..43814ff 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -3,7 +3,7 @@ import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; -import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js'; +import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } 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'; @@ -284,7 +284,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function sortSlot(slot, side) { if (game.phase !== 'running') return; const scanner = scannerBySlot(game, slot); - if (!scanner) return; + sortScanner(scanner, side); + } + + function sortScanner(scanner, side) { + if (game.phase !== 'running' || !scanner || scanner.kind !== 'manual') return; scanner.keyPressTime = 0.18; scanner.keyPressSide = side; if (!scanner.queue.length) return; @@ -292,6 +296,51 @@ 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 sortChickByIndex(index, side, auto) { const chick = game.chicks[index]; if (!chick || chick.stage !== 'queued') return false; @@ -299,9 +348,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck if (!scanner) return false; const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true); if (!plan) { + if (!auto && scanner.kind === 'manual') resetManualCombo(scanner); 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 = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`; chick.route = plan.route; @@ -502,25 +556,12 @@ 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 8f208d4..2920238 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'; -export const CONTRACT_TARGETS = [ +const CONTRACT_TARGETS = [ { target: 'female', title: 'Premium Female Shipment', @@ -107,10 +107,6 @@ 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 d79f639..addc196 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -82,10 +82,6 @@ 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); @@ -117,10 +113,6 @@ 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); @@ -146,18 +138,7 @@ export function explosionDamageForPrice(price, game = null) { return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game))); } -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) { +function positivePayout(_game, amount) { return Math.max(0, Math.ceil(amount)); } @@ -243,6 +224,8 @@ 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 }; } @@ -269,21 +252,6 @@ 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); @@ -337,6 +305,3 @@ 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 deleted file mode 100644 index 65dd54e..0000000 --- a/src/systems/gameEvents.js +++ /dev/null @@ -1,39 +0,0 @@ -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 d71d435..4f9ca52 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -1,5 +1,5 @@ export function cleanScanner(scanner) { - return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 }; + return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 }; } export function snapshot(game) { return JSON.stringify({ diff --git a/src/systems/maintenance.js b/src/systems/maintenance.js index 32ebbf2..a5bec62 100644 --- a/src/systems/maintenance.js +++ b/src/systems/maintenance.js @@ -101,10 +101,6 @@ 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; @@ -221,6 +217,8 @@ 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 }; } @@ -285,7 +283,7 @@ export function updateRepairman(game, dt) { if (targetWear(target) <= 0.001) r.target = null; } -export function worstDegradation(game) { +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 08c8289..80d3449 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -29,14 +29,6 @@ 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 = []; @@ -57,21 +49,6 @@ 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 // ----------------------------------------------------------------------------- @@ -95,34 +72,11 @@ 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; @@ -271,7 +225,7 @@ export function commitFactoryGraphForDay(game) { return graph; } -export function graphSummary(graph) { +function graphSummary(graph) { if (!graph) return null; return { version: graph.version, @@ -296,11 +250,6 @@ 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; @@ -453,13 +402,6 @@ 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); @@ -552,6 +494,44 @@ 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/uiSystem.js b/src/systems/uiSystem.js index ee6bfe6..8fb2fac 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -45,14 +45,17 @@ 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; + btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt || manualLimitReached; if (btn.disabled && game.buildTool === id) game.buildTool = null; btn.classList.toggle('unaffordable', unaffordable); - btn.classList.toggle('already-built', uniqueAlreadyBuilt); + btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached); btn.title = unaffordable ? `Need ${yen(price - game.cash)} more` - : uniqueAlreadyBuilt + : manualLimitReached + ? 'Manual Scanner limit reached (8 max)' + : uniqueAlreadyBuilt ? `${FACILITY_DEFS[id]?.name || id} already exists` : ''; } @@ -104,7 +107,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act ? `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'}`, - `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}`, + `Manual combo: ${game.manualCombo?.count || 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}`, game.cardEffects?.usedMachineActive ? 'Procurement: USED MACHINE MODE / no refunds / 60% durability' : '', issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); @@ -143,6 +146,7 @@ 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); @@ -164,6 +168,16 @@ 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(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); @@ -173,7 +187,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`; + ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`; 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; @@ -194,6 +208,7 @@ 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; @@ -209,16 +224,50 @@ 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 = ` -

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

-

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

`; +
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)}

`; 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; @@ -256,5 +305,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, showGameOver, hideModal, checkGameOver }; + return { updatePanels, updateUI, showTitle, showTurnResult, showEmergencyLoanDecision, showGameOver, hideModal, checkGameOver }; } diff --git a/styles.css b/styles.css index dae2754..aaadd72 100644 --- a/styles.css +++ b/styles.css @@ -202,6 +202,8 @@ 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); } @@ -245,6 +247,7 @@ 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; } @@ -334,6 +337,22 @@ 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; From b54f36c0e897dbb9b55997602ff4b681ca0ec071 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 8 Jun 2026 22:05:54 +0900 Subject: [PATCH 2/2] hehehe --- index.html | 25 ++- src/core/balance.js | 243 ++++++++++++++++++++++++- src/core/config.js | 2 + src/core/entities.js | 13 +- src/core/gridExpansion.js | 160 +++++++++++++++++ src/core/mapGen.js | 22 +++ src/core/state.js | 87 ++++++--- src/core/utils.js | 12 ++ src/game.js | 301 +++++++++++++++++++++++++++++-- src/render/draw.js | 154 +++++++++++----- src/systems/buildSystem.js | 241 +++++++++++++++++++++++-- src/systems/cards.js | 319 ++++++++++----------------------- src/systems/chickSystem.js | 208 ++++++++++++++++----- src/systems/economy.js | 13 +- src/systems/history.js | 28 ++- src/systems/routing.js | 110 +++++++++--- src/systems/selectionSystem.js | 1 + src/systems/uiSystem.js | 24 ++- styles.css | 227 ++++++++++++++++++++++- 19 files changed, 1768 insertions(+), 422 deletions(-) create mode 100644 src/core/gridExpansion.js diff --git a/index.html b/index.html index efb67fa..362d1cd 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v27.0 + Chick Sorter v27.6 @@ -17,6 +17,7 @@
NET¥0
DAY1
PHASETitle
+
COMBO0
@@ -35,8 +36,8 @@
-

CHICK SORTER v27.0

-

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

+

CHICK SORTER v27.6

+

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

@@ -54,6 +55,7 @@ +
Build tools unlock after each day.
@@ -70,6 +72,8 @@
+
+
@@ -77,6 +81,19 @@
+ +
+ DBG +
+ + + + + + +
+
+
@@ -88,6 +105,6 @@ - + diff --git a/src/core/balance.js b/src/core/balance.js index 647d0a1..6f8acf8 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.0 cleanup build', + version: 'v27.6 direct conveyor chick movement', time: { daySeconds: 60, farmShutdownGraceSeconds: 10 @@ -10,11 +10,18 @@ export const BALANCE = { starting: 250 }, grid: { - x: 190, - y: 142, - cols: 22, - rows: 13, - cell: 46 + x: 170, + y: 78, + cols: 20, + rows: 10, + cell: 46, + expansion: { + colsPerPurchase: 10, + rowsPerPurchase: 10, + costBase: 1000, + costMultiplier: 4 / 3, + truncateUnit: 100 + } }, map: { blockedRatio: 0.075 @@ -62,12 +69,232 @@ 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, @@ -94,8 +321,8 @@ export const BALANCE = { }, repairman: { dailyCost: 100, - secondsPerOnePercent: 1 / 3, - walkSpeed: 140 + secondsPerOnePercent: 0.5, + walkSpeed: 90 } }, facilities: { diff --git a/src/core/config.js b/src/core/config.js index 68e13cf..9997553 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -18,6 +18,8 @@ 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; diff --git a/src/core/entities.js b/src/core/entities.js index fe24a10..f769b41 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -22,6 +22,7 @@ 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 }; @@ -97,15 +98,17 @@ export function rollChickSex(game) { export function createChick(game, routeData) { const sex = rollChickSex(game); - const start = routeData.route[0]; + const route = (routeData.route || []).map(p => ({ x: p.x, y: p.y })); + const start = route[0]; return { id: game.nextId++, sex, x: start.x, y: start.y, - route: routeData.route, - targetIndex: 1, - stage: 'input', - scannerId: routeData.scannerId, + route, + targetIndex: route.length > 1 ? 1 : route.length, + stage: routeData.stage || 'belt', + scannerId: routeData.scannerId ?? null, 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 new file mode 100644 index 0000000..83e040d --- /dev/null +++ b/src/core/gridExpansion.js @@ -0,0 +1,160 @@ +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 4b8b51f..15712ad 100644 --- a/src/core/mapGen.js +++ b/src/core/mapGen.js @@ -41,3 +41,25 @@ 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 ae7550a..b0c45c0 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,7 +1,8 @@ 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 { randomBetween, key, inGrid } from './utils.js'; +import { resetOwnedCells } from './gridExpansion.js'; +import { randomBetween, key, inGrid, directionNameBetweenCells } from './utils.js'; export function newTurnStats() { return { @@ -96,6 +97,12 @@ 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, @@ -106,7 +113,7 @@ export function createGame() { selectionBox: null, groupDrag: null, pan: null, - view: { x: 0, y: 0 }, + view: { x: 0, y: 0, scale: 1 }, hover: null, chicks: [], effects: [], @@ -128,12 +135,12 @@ 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, 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, dudFilter: 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 + GRID.rows * GRID.cell + 84, target: null, repairedToday: 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 }, stats: newTurnStats(), lastResult: null, totals: newTotalStats() @@ -165,34 +172,34 @@ function facilityBodyForEntry(id, entry, side) { export function defaultFacilities() { return { - // Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge. - mixer: facilityBodyForEntry('mixer', { col: 0, row: 5 }, 'left'), - trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'), - truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom') + // 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') }; } const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ - // Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it. - [4, 1], [4, 2], [4, 3], [4, 4], + // 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], // S1 left output -> Mixer receiver on the left grid edge. - [3, 5], [2, 5], [1, 5], [0, 5], + [7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4], // S1 right output -> S2 top input. - [5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5], - [11, 6], [11, 7], + [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6], // S2 left output -> Waste Shredder receiver on bottom grid edge. - [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], + [14, 7], [13, 7], [12, 7], [12, 8], [12, 9], // S2 right output -> Truck receiver on the bottom grid edge. - [12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12] + [16, 7], [17, 7], [18, 7], [18, 8], [18, 9] ]); const INITIAL_SCANNERS = Object.freeze([ - { 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', 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: 11, row: 8, 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: 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' } } } ]); const INITIAL_EGG_FARMS = Object.freeze([ - { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null } + { type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null } ]); function addConveyorTile(game, col, row, options = {}) { @@ -205,11 +212,34 @@ function addConveyorTile(game, col, row, options = {}) { builtSession: null, uses: 0, durability: BALANCE.maintenance.durability.conveyor, - maintenanceType: 'conveyor' + maintenanceType: 'conveyor', + dir: options.dir || null, + outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : [] }); + } 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 }, @@ -243,7 +273,9 @@ function protectedInitialCells(game) { } function installInitialConveyorBackbone(game) { - for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row); + 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]); } function scrubBlockedCellsFromInitialBackbone(game) { @@ -252,6 +284,21 @@ 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(); diff --git a/src/core/utils.js b/src/core/utils.js index 1e9747e..64168b3 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -21,6 +21,18 @@ 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 d5779ed..f652d66 100644 --- a/src/game.js +++ b/src/game.js @@ -1,7 +1,8 @@ -import { TURN_SECONDS, THEME } from './core/config.js'; +import { GRID, 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, yen } from './core/utils.js'; +import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js'; +import { expansionLots, lotBounds } from './core/gridExpansion.js'; import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js'; import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { undo, redo } from './systems/history.js'; @@ -9,7 +10,7 @@ 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 { conveyorSpeedForGame, createCardSystem } from './systems/cards.js'; +import { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } 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'; @@ -19,13 +20,25 @@ 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'), + money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'), 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') @@ -48,6 +61,15 @@ 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; @@ -62,13 +84,60 @@ function currentConveyorSpeed(conveyorKey = null) { function startGame() { Object.assign(game, createGame()); game.phase = 'running'; - game.view = { x: 0, y: 0 }; + game.view = { x: 0, y: 0, scale: 1 }; 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() { @@ -87,8 +156,8 @@ function startNextTurn() { ensureMaintenanceState(game); commitFactoryGraphForDay(game); activateRepairmanForDay(game); - const zundaBasisCash = game.cash; - const zunda = collectZundaTax(game, zundaBasisCash); + const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)); + const zunda = collectZundaTax(game, zundaBasisProfit); 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); @@ -106,12 +175,12 @@ function startNextTurn() { game.floatingTexts = []; game.shake = { time: 0, strength: 0 }; game.truckCargo = []; - game.view = { x: 0, y: 0 }; + resetCameraToFactoryStart(); game.stats = newTurnStats(); - game.stats.zundaTax = game.lastStartFees?.zundaTax || 0; + game.stats.zundaTax = 0; game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0; game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0; - const carriedStartCosts = game.stats.zundaTax + game.stats.loanRepayment + game.stats.repairWorkerWages; + const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages; if (carriedStartCosts > 0) { game.stats.penalty += carriedStartCosts; game.stats.profit -= carriedStartCosts; @@ -234,21 +303,96 @@ 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); 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 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 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; @@ -298,7 +442,115 @@ function clickSelect(event) { 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); @@ -306,8 +558,14 @@ 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); @@ -322,7 +580,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.buildAtCell(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); + if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y)); if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event)); }); canvas.addEventListener('pointerup', event => { @@ -331,11 +589,12 @@ 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', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); }); +canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); 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; @@ -345,7 +604,11 @@ 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.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); +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; @@ -361,8 +624,8 @@ window.addEventListener('keydown', event => { 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)) uiSystem.updatePanels(); } - if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } + 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(); } }); @@ -372,4 +635,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(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); +initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update); diff --git a/src/render/draw.js b/src/render/draw.js index 99c8e73..fe00cfb 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -1,4 +1,5 @@ -import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; +import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; +import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.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 { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; @@ -42,8 +43,11 @@ 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; - ctx.translate(game.view.x + sx, game.view.y + sy); + const scale = game.view?.scale || 1; + ctx.translate((game.view?.x || 0) + sx, (game.view?.y || 0) + sy); + ctx.scale(scale, scale); drawGrid(ctx, game); + drawExpansionOffers(ctx, game); drawConveyors(ctx, game); drawScannerConnectors(ctx, game); drawFacilities(ctx, game); @@ -85,11 +89,11 @@ function drawDisconnectedWarnings(ctx, game) { ctx.fillStyle = THEME.danger; ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; - ctx.font = '900 18px ui-monospace, monospace'; + 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 9px ui-monospace, monospace'; + 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(); @@ -100,7 +104,7 @@ 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 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(); + 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(); } function drawImageIfLoaded(ctx, image, x, y, w, h) { if (!image?.loaded) return false; @@ -117,22 +121,38 @@ 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; - 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(); + 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); } - 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(); + 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 (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)'; @@ -142,6 +162,40 @@ 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; @@ -236,8 +290,22 @@ 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); @@ -266,7 +334,7 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) { function drawScannerConnectors(ctx, game) { ctx.save(); ctx.lineWidth = 2; - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; for (const scanner of game.scanners) { const outputs = scannerOutputs(scanner); @@ -294,13 +362,18 @@ 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 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); + 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); const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0)); if (shutter > 0) { const h = 56 * shutter; @@ -308,7 +381,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 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); } + if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 12px 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); @@ -340,9 +413,9 @@ 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 13px ui-monospace, monospace'; ctx.textAlign = 'center'; + ctx.font = '900 19px 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 9px ui-monospace, monospace'; + 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); @@ -350,11 +423,11 @@ function drawScanner(ctx, scanner, game) { if (scanner.kind === 'manual') { drawManualKeyboardIcon(ctx, scanner, c); ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; - ctx.font = '900 8px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`Q:${q}`, c.x, c.y + 32); } else { ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 12px 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); } @@ -378,7 +451,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 13px ui-monospace, monospace'; + ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; for (let i = 0; i < labels.length; i += 1) { const keyDef = labels[i]; @@ -397,7 +470,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) { ctx.fillText(keyDef.text, x + 14, y + 13); } ctx.fillStyle = THEME.muted; - ctx.font = '900 6px ui-monospace, monospace'; + ctx.font = '900 8px ui-monospace, monospace'; ctx.fillText('KEYBOARD', c.x, baseY - 7); ctx.restore(); } @@ -417,12 +490,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 10px ui-monospace, monospace'; + ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillText(title, x + 34, y + 13); if (subtitle) { ctx.fillStyle = THEME.muted; - ctx.font = '900 8px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(subtitle, x + 34, y + 25); } ctx.restore(); @@ -447,7 +520,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 8px ui-monospace, monospace'; + ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.ink; ctx.fillText(info.title, c.x, c.y + 20); @@ -484,8 +557,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 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); + 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); 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(); } @@ -499,7 +572,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 15px 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 19px 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); @@ -512,7 +585,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 15px 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 19px 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'); @@ -566,7 +639,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 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); } + if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); } ctx.restore(); } function selectionToken(type, id) { return `${type}:${id}`; } @@ -587,7 +660,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 9px 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 11px 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. @@ -602,7 +675,8 @@ 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(); - ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height); + const scale = game.view?.scale || 1; + ctx.rect(-(game.view?.x || 0) / scale, -(game.view?.y || 0) / scale, canvas.width / scale, canvas.height / scale); for (const item of items) { const b = item.bounds; const pad = 8; @@ -614,11 +688,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, 96 - game.view.y, 420, 42, true, true); + rect(ctx, (260 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 420 / scale, 42 / scale, 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, 122 - game.view.y); + 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.restore(); return; } @@ -627,11 +701,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, 96 - game.view.y, 560, 42, true, true); + rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y); + ctx.fillText(msg, (496 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale); ctx.restore(); } @@ -669,7 +743,7 @@ function drawEffects(ctx, game) { } } function drawFloatingTexts(ctx, game) { - ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; + ctx.save(); ctx.font = '900 19px 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(); } @@ -700,7 +774,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 9px ui-monospace, monospace'; + ctx.font = '900 11px 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); @@ -710,12 +784,12 @@ function drawRepairman(ctx, game) { function drawCanvasHints(ctx, game, canvas) { ctx.save(); - ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink; + ctx.font = '900 19px 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 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; + ctx.font = '900 19px 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/systems/buildSystem.js b/src/systems/buildSystem.js index a9ee9d2..b0ebb7f 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,7 +1,10 @@ +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 { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.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 { TEXT, equipmentName } from '../core/text.js'; import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js'; import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; @@ -14,8 +17,95 @@ import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScanne export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } - 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)); } + 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 rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; } function rectsOverlap(a, b, margin = 10) { @@ -93,24 +183,133 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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') { - 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 }; + const result = buildConveyorCell(cell, null); + if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied); 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.'); @@ -374,8 +573,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function positionEquipmentPopover(obj) { const rect = canvas.getBoundingClientRect(); const p = selectedPopoverWorldPoint(obj); - const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18; - const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12; + const 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 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`); @@ -410,6 +610,7 @@ 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 = []; @@ -457,7 +658,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('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.'); + lines.push('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.'); 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))}`); @@ -515,6 +716,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, selectedInfoLines, flavorText, disconnectedWarningFor, + gridExpansionCost, buyGridExpansion, expansionOfferAtPoint, + beginConveyorDrag, continueConveyorDrag, endConveyorDrag, removeSelected, switchScannerRole, showManualScannerMenu, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, diff --git a/src/systems/cards.js b/src/systems/cards.js index 10dbf56..2ea3553 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, 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, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, 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,221 +11,7 @@ const RARE_WEIGHT = CARD_BALANCE.rareWeight; const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45; const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize; -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.' - } -]; - -const CARD_TAGS = { - upgradeEgg: ['EGG', 'ACTIVE'], - upgradeAutoScanner: ['SCANNER', 'ACTIVE'], - upgradeMixer: ['ECONOMY', 'ACTIVE'], - upgradeTruck: ['ECONOMY', 'ACTIVE'], - upgradeTrash: ['POOP', 'ACTIVE'], - crowdedFarming: ['EGG', 'RISK', 'PASSIVE'], - hatchingFeed: ['EGG', 'POOP', 'PASSIVE'], - safetyCover: ['RISK', 'PASSIVE'], - preventiveMaintenance: ['MAINTENANCE', 'PASSIVE'], - durabilityCoating: ['MAINTENANCE', 'PASSIVE'], - bearing: ['CONVEYOR', 'PASSIVE'], - extraEggOutlet: ['EGG', 'RARE', 'PASSIVE'], - recyclingSubsidy: ['ECONOMY', 'RARE', 'PASSIVE'], - dynamite: ['RISK', 'RARE', 'ONE-SHOT'], - usedMachine: ['ECONOMY', 'RISK', 'RARE'], - newMachine: ['ECONOMY', 'ONE-SHOT'], - flattery: ['ECONOMY', 'RARE', 'ONE-SHOT'], - loan: ['ECONOMY', 'RISK', 'RARE'], - extraCards: ['RARE', 'ACTIVE'], - legalWork: ['ECONOMY', 'RARE', 'PASSIVE'], - chemicalWeaponSubsidy: ['POOP', 'ULTRA RARE', 'PASSIVE'], - laborExploitation: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'], - rescueLoan: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT'] -}; - -for (const card of CARD_DEFS) card.tags = CARD_TAGS[card.id] || []; +export const CARD_DEFS = CARD_DEFINITIONS; function effectCount(game, id) { const effects = ensureCardState(game); @@ -233,8 +19,8 @@ function effectCount(game, id) { } export function ensureCardState(game) { - if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] }; - for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) { + if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] }; + for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) { if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v; } if (game.cardEffects.flattery != null) { @@ -254,8 +40,8 @@ export function eggProductionDelayMultiplier(game) { return crowded * feed; } -export function extraEggOutletCount(game) { - return Math.min(3, effectCount(game, 'extraEggOutlet')); +export function extraEggOutletCount(_game, farm = null) { + return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0)); } export function conveyorSpeedForGame(game, conveyorKey = null) { @@ -281,11 +67,48 @@ 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`; } @@ -328,6 +151,9 @@ 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] : []; @@ -338,16 +164,26 @@ 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; @@ -382,11 +218,18 @@ function dudCard() { }; } -function applyDudsToChoices(choices) { +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) { const result = [...choices]; let duds = 0; const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0); - const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); + const chance = dudChanceForGame(game); for (let i = 0; i < result.length && duds < maxDuds; i += 1) { if (Math.random() < chance) { result[i] = dudCard(); @@ -409,13 +252,14 @@ 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(choices); + return applyDudsToChoices(game, 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}`; @@ -427,7 +271,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') { + if (target.type === 'eggFarm' || target.type === 'eggOutlet') { 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 }; } @@ -460,6 +304,15 @@ 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); @@ -484,12 +337,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; @@ -560,12 +413,24 @@ 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') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`; + 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 === '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.'; diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index 43814ff..de6546e 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -1,8 +1,8 @@ -import { GRID, THEME } from '../core/config.js'; +import { DIRS, GRID, THEME } from '../core/config.js'; import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; -import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; -import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; +import { 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 { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; @@ -17,35 +17,23 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function spawnChick(farm) { const farmCenter = cellCenter(farm.col, farm.row); - 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) { + const maxOutputs = 1 + extraEggOutletCount(game, farm); + const starts = farmOutputStarts(farm, maxOutputs); + if (!starts.length) { suppressUnconnectedFarmSpawn(farm, farmCenter); return true; } let spawned = 0; let blocked = 0; - for (const data of routes) { - const start = data.route[0]; + for (const startCell of starts) { + const start = cellCenter(startCell.col, startCell.row); const blocker = spawnTileBlocker(start); if (blocker) { blocker.stoppedTimer = 0.85; blocked += 1; continue; } - const chick = createChick(game, data); + const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] }); if (chick.sex === 'poop') countPoopSpawned(game); game.chicks.push(chick); recordFarmProduction(game, farm); @@ -66,6 +54,15 @@ 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; @@ -138,32 +135,132 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } moveAlongRoute(chick, dt); if (chick.route && chick.targetIndex < chick.route.length) return; - if (chick.stage === 'input' || chick.stage === 'toScanner') { - const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId); - if (!scanner) { - removeChick(index, 'NO SCANNER'); - return; - } - enqueueChick(chick, scanner, chick.route); + if (chick.pendingScannerId) { + const scanner = scannerById(game, chick.pendingScannerId); + chick.pendingScannerId = null; + if (!scanner) removeChick(index, 'NO SCANNER'); + else enqueueChick(chick, scanner, chick.route); return; } - if (chick.stage === 'toMixer') resolveMixer(index); - else if (chick.stage === 'toTruck') resolveTruck(index); - else if (chick.stage === 'toTrash') resolveTrash(index); + advanceByCurrentConveyor(chick, 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 (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; + 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; } return false; } - function routeProgress(chick) { - return chick.targetIndex || 0; + 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 conveyorKeyFromPoint(p) { @@ -341,13 +438,35 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck 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 plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true); - if (!plan) { + const startCell = scannerOutputStartCell(scanner, side); + if (!startCell) { if (!auto && scanner.kind === 'manual') resetManualCombo(scanner); floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); return false; @@ -357,14 +476,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck else resetManualCombo(scanner); } scanner.queue = scanner.queue.filter(id => id !== chick.id); - chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`; - chick.route = plan.route; - chick.targetIndex = 1; - chick.nextScannerId = plan.nextScannerId || null; + chick.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.queueRoute = null; chick.queueIndex = -1; - scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination)); - floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination)); + 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); return true; } diff --git a/src/systems/economy.js b/src/systems/economy.js index addc196..97d9484 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -230,12 +230,15 @@ export function settleTruckRevenue(game) { return { base, adjusted, target, targetCount, unitPrice }; } -export function collectZundaTax(game, basisCash = game.cash) { - const info = zundaTaxInfo(basisCash, game); +export function collectZundaTax(game, basisProfit = 0) { + const info = zundaTaxInfo(basisProfit, game); if (info.tax <= 0) return info; - game.stats.zundaTax += info.tax; - game.totals.zundaTax += info.tax; - applyPenalty(game, info.tax); + // 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; return info; } diff --git a/src/systems/history.js b/src/systems/history.js index 4f9ca52..e121ae8 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -1,3 +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 }; } @@ -7,6 +9,16 @@ 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, @@ -28,10 +40,24 @@ 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 }; + game.view = data.view || { x: 0, y: 0, scale: 1 }; + if (game.view.scale == null) game.view.scale = 1; 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/routing.js b/src/systems/routing.js index 80d3449..3bad2fc 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -94,6 +94,14 @@ 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, @@ -132,7 +140,9 @@ export function buildFactoryGraph(game) { for (const k of graph.cells) { const p = graphCell(k); - for (const d of DIRS) { + const explicitDirs = conveyorOutDirNames(game, k); + const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : 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 }); } @@ -270,38 +280,68 @@ 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' }]; - const visited = new Set([startState]); - const parent = new Map(); + 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 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); + if (isGoal(cur)) { + found.push(curStateK); + bestGoalDepth = cur.depth; + if (found.length >= maxGoalStates) break; + continue; + } 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}`; - 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 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); + } } } - 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); + + 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; } - return reversed.reverse(); - }); - if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p })))); - return paths; + 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; } export function buildConveyorComponents(game) { @@ -363,6 +403,25 @@ 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) => { @@ -376,9 +435,14 @@ 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; - if (advance) game.branchCounters.set(id, n + 1); - return sorted[n % sorted.length]; + game.branchCounters.set(id, n + 1); + return pool[Math.floor(Math.random() * pool.length)]; } export function scannerRules(scanner) { diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index 02348e5..e5bc635 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -159,6 +159,7 @@ 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 8fb2fac..639aeda 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -22,6 +22,7 @@ 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; } @@ -60,6 +61,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act : ''; } 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); @@ -67,7 +77,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 3% durability per second.`; + ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 2% durability per second.`; } const cashout = noAffordableBuildTools(); ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout); @@ -93,21 +103,22 @@ 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 tax = zundaTaxInfo(game.cash, game); + const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)); + const tax = zundaTaxInfo(zundaBasisProfit, 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')} | Blocked: ${game.blockedCells?.size || 0} cells`, + `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`, `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)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, + `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)}`, 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} | 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}`, + `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}`, game.cardEffects?.usedMachineActive ? 'Procurement: USED MACHINE MODE / no refunds / 60% durability' : '', issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); @@ -156,6 +167,7 @@ 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); @@ -178,7 +190,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act 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(game.cash, game).tax; + const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), 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); } diff --git a/styles.css b/styles.css index aaadd72..859a54f 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(5, minmax(86px, auto)); } +.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(6, minmax(82px, 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, .cash-positive { color: var(--green) !important; } +.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo 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,6 +66,76 @@ 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); } @@ -479,3 +549,156 @@ 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; } +}