fiirst
This commit is contained in:
parent
ba055a7f13
commit
a322158f43
20 changed files with 3383 additions and 0 deletions
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
16
assets/images/README.md
Normal file
16
assets/images/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Image asset folder
|
||||
|
||||
The game currently uses simple canvas placeholders. You can override them by adding images with these filenames:
|
||||
|
||||
- `chick_male.png`
|
||||
- `chick_female.png`
|
||||
- `poop.png`
|
||||
- `conveyor.png`
|
||||
- `scanner_manual.png`
|
||||
- `scanner_auto.png`
|
||||
- `egg_farm.png`
|
||||
- `mixer.png`
|
||||
- `shredder.png`
|
||||
- `truck.png`
|
||||
|
||||
The render code checks whether each image loads and falls back to simple shapes when it does not.
|
||||
98
index.html
Normal file
98
index.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chick Sorter v10.7 UI / Ports / Shutter</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="gameShell" class="game-shell" aria-label="Game screen">
|
||||
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
|
||||
|
||||
<div class="hud hud-top-left" aria-label="Run status">
|
||||
<div class="hud-card cash"><span>CASH</span><strong id="money">¥200</strong></div>
|
||||
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
|
||||
<div class="hud-card profit"><span>PROFIT</span><strong id="turnProfit">¥0</strong></div>
|
||||
<div class="hud-card"><span>TURN</span><strong id="turn">1</strong></div>
|
||||
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="hud hud-top-right" aria-label="Turn result details">
|
||||
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
|
||||
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="priority-strip" aria-label="Important turn info">
|
||||
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
|
||||
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
|
||||
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
|
||||
</div>
|
||||
|
||||
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v10.7</span></h1>
|
||||
<p>Build, drag, connect all receiver ports, then start the next turn.</p>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Turn</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid">
|
||||
<section class="equipment-section">
|
||||
<h2>Add / Edit Equipment</h2>
|
||||
<div class="large-tools">
|
||||
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
|
||||
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥220</span></button>
|
||||
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 4.5s cooldown</span></button>
|
||||
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
|
||||
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
|
||||
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
|
||||
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
|
||||
<button id="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</span></button>
|
||||
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
|
||||
</div>
|
||||
<div id="buildStatus" class="mini-box">Build tools unlock after each turn.</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="contract-section">
|
||||
<h2>Irregular One-Turn Event</h2>
|
||||
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
|
||||
</section>
|
||||
|
||||
<section class="facility-section">
|
||||
<h2>Selected Facility</h2>
|
||||
<div id="facilityPanel" class="facility-panel-empty">Click equipment in Build phase.</div>
|
||||
</section>
|
||||
|
||||
<section class="rules-section compact-rules">
|
||||
<h2>Status</h2>
|
||||
<div id="turnSummary" class="mini-box"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-dock" aria-label="Sorting controls">
|
||||
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A → Mixer</button>
|
||||
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D → S2</button>
|
||||
<button id="scanner2MixerButton" class="sort-button" type="button">S2 ← Waste</button>
|
||||
<button id="scanner2TruckButton" class="sort-button" type="button">S2 → Truck</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modal" class="modal visible">
|
||||
<div class="modal-card">
|
||||
<h2 id="modalTitle">Chick Sorter</h2>
|
||||
<div id="modalBody"></div>
|
||||
<div id="modalActions" class="modal-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/game.js?v=10.7-ui-ports-shutter"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
src/README.md
Normal file
24
src/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Chick Sorter v6.0 Visibility Pass
|
||||
|
||||
Open `index.html` in a browser.
|
||||
|
||||
## v6 changes
|
||||
|
||||
- During sorting, the Build/Upgrade panel is hidden.
|
||||
- During sorting, the HUD is reduced to the key items: Cash, Time, Profit, Turn, Phase.
|
||||
- Turn detail counters are shown between turns instead of competing with the play field.
|
||||
- Chicks are larger and have a stronger outline for dark backgrounds.
|
||||
- Background and grid lines are fainter to reduce visual noise.
|
||||
|
||||
## Controls
|
||||
|
||||
- Scanner 1: `A` = Mixer, `D` = Truck
|
||||
- Scanner 2: `←` = Mixer, `→` = Truck
|
||||
|
||||
## Rules
|
||||
|
||||
- Mixer: ¥5
|
||||
- Female truck: ¥10
|
||||
- Male truck: -¥10
|
||||
- Unsorted chicks randomly exit left or right at scanner output.
|
||||
- Conveyor speed upgrade is removed.
|
||||
63
src/core/config.js
Normal file
63
src/core/config.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export const VERSION = 'v10.7 UI / Ports / Shutter';
|
||||
|
||||
export const TURN_SECONDS = 60;
|
||||
export const STARTING_CASH = 200;
|
||||
|
||||
export const MIXER_PRICE = 5;
|
||||
export const TRUCK_PRICE = 10;
|
||||
export const POOP_RATE = 0.08;
|
||||
export const MIXER_HALF_SECONDS = 10;
|
||||
export const TRUCK_POOP_DECAY = 0.01;
|
||||
export const CONVEYOR_SPEED = 52;
|
||||
export const CONVEYOR_SPEED_GROWTH = 1.02;
|
||||
export const AUTO_SCANNER_COOLDOWN = 4.5;
|
||||
export const CONTRACT_EVENT_CHANCE = 0.70;
|
||||
export const CONTRACT_EVENT_FIRST_TURN = 8;
|
||||
|
||||
export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 };
|
||||
|
||||
export const BUILD_COSTS = {
|
||||
conveyor: 30,
|
||||
eggFarm: 220,
|
||||
manualScanner: 260,
|
||||
autoScanner: 360,
|
||||
mixer: 450,
|
||||
trash: 240,
|
||||
truck: 450
|
||||
};
|
||||
|
||||
export const FACILITY_PRICES = {
|
||||
mixer: 450,
|
||||
trash: 240,
|
||||
truck: 450
|
||||
};
|
||||
|
||||
export const DIRS = [
|
||||
{ name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' },
|
||||
{ name: 'left', dc: -1, dr: 0, angle: Math.PI, opposite: 'right' },
|
||||
{ name: 'down', dc: 0, dr: 1, angle: Math.PI / 2, opposite: 'up' },
|
||||
{ name: 'up', dc: 0, dr: -1, angle: -Math.PI / 2, opposite: 'down' }
|
||||
];
|
||||
|
||||
export const THEME = {
|
||||
bg: '#eefbea',
|
||||
ink: '#102015',
|
||||
muted: '#526456',
|
||||
green: '#22b94f',
|
||||
greenSoft: '#b7f5c6',
|
||||
mixerBlue: '#2477ff',
|
||||
truckPink: '#ff6aa8',
|
||||
wasteGreen: '#22b94f',
|
||||
danger: '#c42424',
|
||||
warn: '#f0a020',
|
||||
white: '#ffffff',
|
||||
poop: '#7b4a23',
|
||||
male: '#62a9ff',
|
||||
female: '#ff8bc6'
|
||||
};
|
||||
|
||||
export const EFFECT_PRIORITY = {
|
||||
ambient: 0,
|
||||
important: 1,
|
||||
critical: 2
|
||||
};
|
||||
94
src/core/entities.js
Normal file
94
src/core/entities.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js';
|
||||
import { nextSpawnDelay } from './state.js';
|
||||
import { currentPoopRate } from '../systems/contracts.js';
|
||||
|
||||
export function createEggFarm(game, col, row) {
|
||||
const farm = {
|
||||
type: 'eggFarm', id: game.nextId++, col, row,
|
||||
level: 1, nextSpawn: 2, lastInterval: 2,
|
||||
price: BUILD_COSTS.eggFarm,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
farm.nextSpawn = nextSpawnDelay(farm);
|
||||
farm.lastInterval = farm.nextSpawn;
|
||||
return farm;
|
||||
}
|
||||
|
||||
export function createScanner(game, col, row, kind) {
|
||||
const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
|
||||
const manualCount = game.scanners.filter(s => s.kind === 'manual').length;
|
||||
return {
|
||||
type: 'scanner', id: game.nextId++, kind,
|
||||
slot: kind === 'manual' ? manualCount : null,
|
||||
role: manualCount % 2,
|
||||
col, row, level: 1,
|
||||
queue: [], cooldown: 0,
|
||||
price: cost,
|
||||
builtSession: game.buildSession,
|
||||
autoMode: 'standard'
|
||||
};
|
||||
}
|
||||
|
||||
export function nearestGridEdge(point) {
|
||||
const colFloat = (point.x - GRID.x) / GRID.cell;
|
||||
const rowFloat = (point.y - GRID.y) / GRID.cell;
|
||||
const col = Math.max(0, Math.min(GRID.cols - 1, Math.round(colFloat - 0.5)));
|
||||
const row = Math.max(0, Math.min(GRID.rows - 1, Math.round(rowFloat - 0.5)));
|
||||
const left = Math.abs(point.x - GRID.x);
|
||||
const right = Math.abs(point.x - (GRID.x + GRID.cols * GRID.cell));
|
||||
const top = Math.abs(point.y - GRID.y);
|
||||
const bottom = Math.abs(point.y - (GRID.y + GRID.rows * GRID.cell));
|
||||
const side = [['left', left], ['right', right], ['top', top], ['bottom', bottom]].sort((a, b) => a[1] - b[1])[0][0];
|
||||
if (side === 'left') return { side, entry: { col: 0, row } };
|
||||
if (side === 'right') return { side, entry: { col: GRID.cols - 1, row } };
|
||||
if (side === 'top') return { side, entry: { col, row: 0 } };
|
||||
return { side, entry: { col, row: GRID.rows - 1 } };
|
||||
}
|
||||
|
||||
export function layoutFacilityOnEdge(facility, entry, side) {
|
||||
const center = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
|
||||
const edge = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
|
||||
facility.entry = { ...entry };
|
||||
facility.side = side;
|
||||
if (side === 'left') { facility.x = edge.left - facility.w - 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'right') { facility.x = edge.right + 18; facility.y = center.y - facility.h / 2; }
|
||||
else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = edge.top - facility.h - 18; }
|
||||
else { facility.x = center.x - facility.w / 2; facility.y = edge.bottom + 18; }
|
||||
return facility;
|
||||
}
|
||||
|
||||
export function createFacility(game, id, p, price) {
|
||||
const { entry, side } = nearestGridEdge(p);
|
||||
const spec = {
|
||||
mixer: { name: 'Mixer', w: 164, h: 126, price: FACILITY_PRICES.mixer },
|
||||
trash: { name: 'Waste Shredder', w: 180, h: 100, price: FACILITY_PRICES.trash },
|
||||
truck: { name: 'Truck', w: 176, h: 138, price: FACILITY_PRICES.truck }
|
||||
}[id] || { name: id[0].toUpperCase() + id.slice(1), w: 168, h: 110, price };
|
||||
const f = {
|
||||
type: 'facility', id,
|
||||
name: spec.name,
|
||||
x: 0, y: 0, w: spec.w, h: spec.h,
|
||||
level: 1,
|
||||
price: price || spec.price,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
return layoutFacilityOnEdge(f, entry, side);
|
||||
}
|
||||
|
||||
export function createChick(game, routeData) {
|
||||
const sex = Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
|
||||
const start = routeData.route[0];
|
||||
return {
|
||||
id: game.nextId++, sex,
|
||||
x: start.x, y: start.y,
|
||||
route: routeData.route,
|
||||
targetIndex: 1,
|
||||
stage: 'input',
|
||||
scannerId: routeData.scannerId,
|
||||
nextScannerId: null,
|
||||
radius: sex === 'poop' ? 15 : 17,
|
||||
bob: Math.random() * Math.PI * 2,
|
||||
queueIndex: -1,
|
||||
queueRoute: null
|
||||
};
|
||||
}
|
||||
155
src/core/state.js
Normal file
155
src/core/state.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { STARTING_CASH, TURN_SECONDS, BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js';
|
||||
import { randomBetween } from './utils.js';
|
||||
|
||||
export function newTurnStats() {
|
||||
return {
|
||||
mixerCount: 0,
|
||||
truckFemale: 0,
|
||||
truckMale: 0,
|
||||
trashCount: 0,
|
||||
poopSpawned: 0,
|
||||
poopTruck: 0,
|
||||
poopTrash: 0,
|
||||
poopMixer: 0,
|
||||
autoSorted: 0,
|
||||
revenue: 0,
|
||||
penalty: 0,
|
||||
profit: 0,
|
||||
pendingTruckRevenue: 0,
|
||||
explosionDamage: 0,
|
||||
correct: 0,
|
||||
mistakes: 0,
|
||||
contractBonus: 0,
|
||||
contractResult: null
|
||||
};
|
||||
}
|
||||
|
||||
export function newTotalStats() {
|
||||
return {
|
||||
turnsCompleted: 0,
|
||||
processed: 0,
|
||||
correct: 0,
|
||||
mistakes: 0,
|
||||
revenue: 0,
|
||||
penalty: 0,
|
||||
profit: 0,
|
||||
maxCash: STARTING_CASH,
|
||||
autoSorted: 0,
|
||||
poopTruck: 0,
|
||||
poopTrash: 0,
|
||||
poopMixer: 0,
|
||||
explosionDamage: 0,
|
||||
contractBonus: 0,
|
||||
contractSuccess: 0,
|
||||
contractFailed: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function createGame() {
|
||||
return {
|
||||
phase: 'title',
|
||||
cash: STARTING_CASH,
|
||||
turn: 1,
|
||||
timeLeft: TURN_SECONDS,
|
||||
lastTimestamp: 0,
|
||||
nextId: 10,
|
||||
buildTool: null,
|
||||
buildSession: 0,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
selected: null,
|
||||
multiSelected: [],
|
||||
selectionBox: null,
|
||||
groupDrag: null,
|
||||
pan: null,
|
||||
view: { x: 0, y: 0 },
|
||||
debug: false,
|
||||
chicks: [],
|
||||
effects: [],
|
||||
floatingTexts: [],
|
||||
shake: { time: 0, strength: 0 },
|
||||
truckCargo: [],
|
||||
conveyorTiles: new Set(),
|
||||
conveyorMeta: new Map(),
|
||||
eggFarms: [],
|
||||
scanners: [],
|
||||
facilities: {},
|
||||
branchCounters: new Map(),
|
||||
mixerHalfTimer: 0,
|
||||
congestion: new Map(),
|
||||
lastExplodedComponent: new Map(),
|
||||
contractOffer: null,
|
||||
contractActive: null,
|
||||
stats: newTurnStats(),
|
||||
lastResult: null,
|
||||
totals: newTotalStats()
|
||||
};
|
||||
}
|
||||
|
||||
export function getSpawnRange(farm) {
|
||||
const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]];
|
||||
return ranges[(farm?.level || 1) - 1] || ranges[0];
|
||||
}
|
||||
|
||||
export function nextSpawnDelay(farm) {
|
||||
const [min, max] = getSpawnRange(farm);
|
||||
return randomBetween(min, max);
|
||||
}
|
||||
|
||||
function facilityBodyForEntry(id, entry, side) {
|
||||
const sizes = {
|
||||
mixer: { w: 164, h: 126, name: 'Mixer', price: FACILITY_PRICES.mixer },
|
||||
trash: { w: 180, h: 100, name: 'Waste Shredder', price: FACILITY_PRICES.trash },
|
||||
truck: { w: 176, h: 138, name: 'Truck', price: FACILITY_PRICES.truck }
|
||||
};
|
||||
const g = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
|
||||
const c = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
|
||||
const spec = sizes[id];
|
||||
let x = c.x - spec.w / 2;
|
||||
let y = c.y - spec.h / 2;
|
||||
if (side === 'left') { x = g.left - spec.w - 18; y = c.y - spec.h / 2; }
|
||||
else if (side === 'right') { x = g.right + 18; y = c.y - spec.h / 2; }
|
||||
else if (side === 'bottom') { x = c.x - spec.w / 2; y = g.bottom + 18; }
|
||||
else if (side === 'top') { x = c.x - spec.w / 2; y = g.top - spec.h - 18; }
|
||||
return { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
|
||||
}
|
||||
|
||||
export function defaultFacilities() {
|
||||
return {
|
||||
// Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge.
|
||||
mixer: facilityBodyForEntry('mixer', { col: 0, row: 6 }, 'left'),
|
||||
trash: facilityBodyForEntry('trash', { col: 16, row: 12 }, 'bottom'),
|
||||
truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right')
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLayout(game) {
|
||||
game.conveyorTiles.clear();
|
||||
game.conveyorMeta.clear();
|
||||
const initial = [
|
||||
// Egg Farm -> S1 top input. The approach lane is long enough to read the flow.
|
||||
[4, 1], [4, 2], [4, 3], [4, 4], [4, 5],
|
||||
// S1 left output -> Mixer receiver on the left grid edge.
|
||||
[3, 6], [2, 6], [1, 6], [0, 6],
|
||||
// S1 right output -> S2 top input. This avoids touching S2's waste output lane.
|
||||
[5, 6], [6, 6], [7, 6], [8, 6], [9, 6], [10, 6], [11, 6], [12, 6], [13, 6], [14, 6],
|
||||
[14, 5], [15, 5], [16, 5], [17, 5], [17, 6],
|
||||
// S2 left output -> Waste Shredder receiver on bottom grid edge.
|
||||
[16, 7], [16, 8], [16, 9], [16, 10], [16, 11], [16, 12],
|
||||
// S2 right output -> Truck receiver on the right grid edge.
|
||||
[18, 7], [19, 7], [20, 7], [21, 7]
|
||||
]; for (const [col, row] of initial) {
|
||||
const k = `${col},${row}`;
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null });
|
||||
}
|
||||
game.facilities = defaultFacilities();
|
||||
game.scanners = [
|
||||
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 6, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
|
||||
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 17, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
|
||||
];
|
||||
const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null };
|
||||
farm.nextSpawn = nextSpawnDelay(farm);
|
||||
farm.lastInterval = farm.nextSpawn;
|
||||
game.eggFarms = [farm];
|
||||
}
|
||||
32
src/core/utils.js
Normal file
32
src/core/utils.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { GRID } from './config.js';
|
||||
|
||||
export function yen(value) { return `¥${Math.floor(value).toLocaleString('en-US')}`; }
|
||||
export function key(col, row) { return `${col},${row}`; }
|
||||
export function parseKey(k) { const [col, row] = String(k).split(',').map(Number); return { col, row }; }
|
||||
export function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
||||
export function randomBetween(min, max) { return min + Math.random() * (max - min); }
|
||||
export function inGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; }
|
||||
export function cellCenter(col, row) {
|
||||
return { x: GRID.x + col * GRID.cell + GRID.cell / 2, y: GRID.y + row * GRID.cell + GRID.cell / 2 };
|
||||
}
|
||||
export function pointToCell(x, y) {
|
||||
const col = Math.floor((x - GRID.x) / GRID.cell);
|
||||
const row = Math.floor((y - GRID.y) / GRID.cell);
|
||||
if (!inGrid(col, row)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
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 lerp(a, b, t) { return a + (b - a) * t; }
|
||||
export function hexToRgb(hex) {
|
||||
const s = hex.replace('#', '');
|
||||
return { r: parseInt(s.slice(0, 2), 16), g: parseInt(s.slice(2, 4), 16), b: parseInt(s.slice(4, 6), 16) };
|
||||
}
|
||||
export function rgbToHex(r, g, b) {
|
||||
const c = n => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0');
|
||||
return `#${c(r)}${c(g)}${c(b)}`;
|
||||
}
|
||||
export function mixHex(a, b, t) {
|
||||
const ca = hexToRgb(a), cb = hexToRgb(b);
|
||||
return rgbToHex(lerp(ca.r, cb.r, t), lerp(ca.g, cb.g, t), lerp(ca.b, cb.b, t));
|
||||
}
|
||||
424
src/game.js
Normal file
424
src/game.js
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { VERSION, TURN_SECONDS, STARTING_CASH, POOP_RATE, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from './core/config.js';
|
||||
import { createGame, resetLayout, newTurnStats, newTotalStats, nextSpawnDelay, getSpawnRange } from './core/state.js';
|
||||
import { createChick } from './core/entities.js';
|
||||
import { key, parseKey, clamp, randomBetween, pointToCell, cellCenter, distance, yen } from './core/utils.js';
|
||||
import { farmAt, scannerAt, scannerById, scannerBySlot, scannerCenter, scannerConnector, routeFromFarmToScanner, outputRoute, scannerOutputs, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, factoryReady, facilityConnectionIssues } from './systems/routing.js';
|
||||
import { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS, maleTruckPenalty, truckPayoutMultiplier, positivePayout, applyCashDelta, spendCash, refundCash, settleTruckRevenue, finalScore } from './systems/economy.js';
|
||||
import { snapshot, record, undo, redo } from './systems/history.js';
|
||||
import { drawAll } from './render/draw.js';
|
||||
import { createBuildSystem } from './systems/buildSystem.js';
|
||||
import { createUISystem } from './systems/uiSystem.js';
|
||||
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, eraseEffect, rageEffect, updateEffects } from './systems/effects.js';
|
||||
import { rollContractOffer, activateAcceptedContract, clearActiveContract, productionMultiplier, resolveContract, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './systems/contracts.js';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
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'),
|
||||
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'), facilityPanel: document.getElementById('facilityPanel'),
|
||||
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
|
||||
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')
|
||||
}
|
||||
};
|
||||
|
||||
const game = createGame();
|
||||
let build;
|
||||
let uiSystem;
|
||||
|
||||
function conveyorSpeedForTurn(turn) {
|
||||
return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1));
|
||||
}
|
||||
function currentConveyorSpeed() { return conveyorSpeedForTurn(game.turn); }
|
||||
|
||||
function startGame() {
|
||||
Object.assign(game, createGame());
|
||||
game.phase = 'running';
|
||||
resetLayout(game);
|
||||
uiSystem.hideModal();
|
||||
updateCongestion();
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
}
|
||||
|
||||
function startNextTurn() {
|
||||
if (game.phase !== 'build') return;
|
||||
const issues = facilityConnectionIssues(game);
|
||||
if (issues.length) {
|
||||
build.fail(`Cannot start: ${issues[0]}`);
|
||||
uiSystem.updatePanels();
|
||||
return;
|
||||
}
|
||||
activateAcceptedContract(game);
|
||||
game.phase = 'running';
|
||||
game.turn += 1;
|
||||
game.timeLeft = TURN_SECONDS;
|
||||
game.chicks = [];
|
||||
game.effects = [];
|
||||
game.floatingTexts = [];
|
||||
game.shake = { time: 0, strength: 0 };
|
||||
game.truckCargo = [];
|
||||
game.stats = newTurnStats();
|
||||
for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
|
||||
for (const farm of game.eggFarms) { farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
|
||||
game.buildTool = null;
|
||||
game.selected = null;
|
||||
updateCongestion();
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.updateUI();
|
||||
}
|
||||
|
||||
function completeTurn() {
|
||||
if (game.phase !== 'running') return;
|
||||
const ship = settleTruckRevenue(game);
|
||||
const contract = resolveContract(game, applyCashDelta);
|
||||
clearActiveContract(game);
|
||||
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
|
||||
game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash };
|
||||
game.phase = 'build';
|
||||
game.truckCargo = [];
|
||||
game.buildSession += 1;
|
||||
game.undoStack = [];
|
||||
game.redoStack = [];
|
||||
game.timeLeft = 0;
|
||||
game.totals.turnsCompleted += 1;
|
||||
for (const scanner of game.scanners) scanner.queue = [];
|
||||
game.contractOffer = rollContractOffer(game);
|
||||
game.buildTool = null;
|
||||
game.groupDrag = null;
|
||||
game.selectionBox = null;
|
||||
game.pan = null;
|
||||
uiSystem.updatePanels();
|
||||
uiSystem.showTurnResult(game.lastResult);
|
||||
}
|
||||
|
||||
function closeFarmShutters() {
|
||||
for (const farm of game.eggFarms) {
|
||||
farm.shutterProgress = 0.02;
|
||||
farm.shutterSparked = true;
|
||||
const c = cellCenter(farm.col, farm.row);
|
||||
shockwave(game, c.x, c.y, THEME.ink);
|
||||
sparkBurst(game, c.x, c.y + 10, 10);
|
||||
floating(game, c.x, c.y - 34, 'SHUT', THEME.ink);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnChick(farm) {
|
||||
const data = routeFromFarmToScanner(game, farm, true);
|
||||
if (!data) { floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 18, 'NO ROUTE', THEME.danger); return false; }
|
||||
const chick = createChick(game, data);
|
||||
if (chick.sex === 'poop') game.stats.poopSpawned += 1;
|
||||
const start = data.route[0];
|
||||
if (isSpawnBlocked(start)) { explodeRoute(data.route, [{ sex: chick.sex, x: start.x, y: start.y }], 'LINE JAM!'); return false; }
|
||||
game.chicks.push(chick);
|
||||
spawnPulse(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y);
|
||||
return true;
|
||||
}
|
||||
function isSpawnBlocked(start) { return game.chicks.some(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68); }
|
||||
function currentSpawnDelay(farm) { return nextSpawnDelay(farm) / productionMultiplier(game); }
|
||||
|
||||
function update(timestamp) {
|
||||
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
|
||||
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
|
||||
game.lastTimestamp = timestamp;
|
||||
if (game.phase === 'running') updateRunning(dt);
|
||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||
updateCongestion();
|
||||
drawAll(ctx, canvas, game, { activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
|
||||
uiSystem.updateUI();
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
function updateRunning(dt) {
|
||||
const producing = game.timeLeft > 0;
|
||||
game.timeLeft = Math.max(0, game.timeLeft - dt);
|
||||
if (producing && game.timeLeft <= 0) closeFarmShutters();
|
||||
if (game.timeLeft <= 0) {
|
||||
for (const farm of game.eggFarms) farm.shutterProgress = Math.min(1, (farm.shutterProgress || 0) + dt * 2.8);
|
||||
}
|
||||
if (game.mixerHalfTimer > 0) game.mixerHalfTimer = Math.max(0, game.mixerHalfTimer - dt);
|
||||
if (producing && game.timeLeft > 0) {
|
||||
for (const farm of game.eggFarms) {
|
||||
farm.nextSpawn -= dt;
|
||||
if (farm.nextSpawn <= 0) { spawnChick(farm); farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; }
|
||||
}
|
||||
}
|
||||
for (let i = game.chicks.length - 1; i >= 0; i -= 1) {
|
||||
const chick = game.chicks[i];
|
||||
chick.bob += dt * 7;
|
||||
if (chick.stage === 'queued') continue;
|
||||
updateRoutedChick(chick, i, dt);
|
||||
}
|
||||
updateScannerQueues(dt);
|
||||
updateCongestion();
|
||||
checkCongestionExplosions(dt);
|
||||
if (game.timeLeft <= 0 && game.chicks.length === 0) completeTurn();
|
||||
uiSystem.checkGameOver();
|
||||
}
|
||||
|
||||
function updateRoutedChick(chick, index, dt) {
|
||||
if (blockedByFrontChick(chick)) return;
|
||||
moveAlongRoute(chick, dt);
|
||||
if (chick.route && chick.targetIndex < chick.route.length) return;
|
||||
if (chick.stage === 'input' || chick.stage === 'toScanner') {
|
||||
const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId);
|
||||
if (!scanner) { removeChick(index, 'NO SCANNER'); return; }
|
||||
enqueueChick(chick, scanner, chick.route);
|
||||
return;
|
||||
}
|
||||
if (chick.stage === 'toMixer') resolveMixer(index);
|
||||
else if (chick.stage === 'toTruck') resolveTruck(index);
|
||||
else if (chick.stage === 'toTrash') resolveTrash(index);
|
||||
}
|
||||
function blockedByFrontChick(chick) {
|
||||
if (!chick.route || chick.targetIndex >= chick.route.length) return false;
|
||||
const next = chick.route[chick.targetIndex];
|
||||
for (const other of game.chicks) {
|
||||
if (other.id === chick.id || other.stage === 'flying') continue;
|
||||
if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function routeProgress(chick) { return chick.targetIndex || 0; }
|
||||
function moveAlongRoute(chick, dt) {
|
||||
let remaining = currentConveyorSpeed() * dt;
|
||||
while (remaining > 0 && chick.targetIndex < chick.route.length) {
|
||||
const target = chick.route[chick.targetIndex];
|
||||
const dx = target.x - chick.x, dy = target.y - chick.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= 0.001) { chick.targetIndex += 1; continue; }
|
||||
if (remaining >= dist) { chick.x = target.x; chick.y = target.y; chick.targetIndex += 1; remaining -= dist; }
|
||||
else { chick.x += dx / dist * remaining; chick.y += dy / dist * remaining; remaining = 0; }
|
||||
}
|
||||
}
|
||||
function enqueueChick(chick, scanner, routeOverride) {
|
||||
chick.stage = 'queued';
|
||||
chick.route = null;
|
||||
chick.queueRoute = routeOverride ? routeOverride.map(p => ({ x: p.x, y: p.y })) : [scannerCenter(scanner)];
|
||||
chick.scannerId = scanner.id;
|
||||
if (!scanner.queue.includes(chick.id)) scanner.queue.push(chick.id);
|
||||
positionScannerQueue(scanner);
|
||||
}
|
||||
function positionScannerQueue(scanner) {
|
||||
scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued'));
|
||||
const spacing = GRID.cell * 0.86;
|
||||
scanner.queue.forEach((id, idx) => {
|
||||
const chick = game.chicks.find(ch => ch.id === id);
|
||||
if (!chick) return;
|
||||
setPositionFromRouteEnd(chick, chick.queueRoute || [scannerCenter(scanner)], idx * spacing);
|
||||
chick.queueIndex = idx;
|
||||
});
|
||||
}
|
||||
function setPositionFromRouteEnd(chick, route, distanceBack) {
|
||||
if (!route || !route.length) return;
|
||||
if (route.length === 1 || distanceBack <= 0) { const end = route[route.length - 1]; chick.x = end.x; chick.y = end.y; return; }
|
||||
let remain = distanceBack;
|
||||
for (let i = route.length - 1; i > 0; i -= 1) {
|
||||
const a = route[i - 1], b = route[i];
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (remain <= len) { const t = 1 - remain / len; chick.x = a.x + (b.x - a.x) * t; chick.y = a.y + (b.y - a.y) * t; return; }
|
||||
remain -= len;
|
||||
}
|
||||
const start = route[0]; chick.x = start.x; chick.y = start.y;
|
||||
}
|
||||
function updateScannerQueues(dt) {
|
||||
for (const scanner of game.scanners) {
|
||||
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
|
||||
positionScannerQueue(scanner);
|
||||
if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue;
|
||||
const id = scanner.queue[0];
|
||||
const index = game.chicks.findIndex(c => c.id === id);
|
||||
if (index < 0) { scanner.queue.shift(); continue; }
|
||||
if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) { scanner.cooldown = AUTO_SCANNER_COOLDOWN; game.stats.autoSorted += 1; game.totals.autoSorted += 1; }
|
||||
}
|
||||
}
|
||||
function autoSideFor(scanner, chick) {
|
||||
if (scanner.role === 0) return chick.sex === 'male' ? 'left' : 'right';
|
||||
return chick.sex === 'poop' ? 'left' : 'right';
|
||||
}
|
||||
function activeQueuedChick(slot) {
|
||||
const scanner = scannerBySlot(game, slot);
|
||||
if (!scanner || !scanner.queue.length) return null;
|
||||
return game.chicks.find(c => c.id === scanner.queue[0]) || null;
|
||||
}
|
||||
function sortSlot(slot, side) {
|
||||
if (game.phase !== 'running') return;
|
||||
const scanner = scannerBySlot(game, slot);
|
||||
if (!scanner || !scanner.queue.length) return;
|
||||
const index = game.chicks.findIndex(c => c.id === scanner.queue[0]);
|
||||
if (index >= 0) sortChickByIndex(index, side, false);
|
||||
}
|
||||
function sortChickByIndex(index, side, auto) {
|
||||
const chick = game.chicks[index];
|
||||
if (!chick || chick.stage !== 'queued') return false;
|
||||
const scanner = scannerById(game, chick.scannerId);
|
||||
if (!scanner) return false;
|
||||
const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true);
|
||||
if (!plan) { floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); return false; }
|
||||
scanner.queue = scanner.queue.filter(id => id !== chick.id);
|
||||
chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`;
|
||||
chick.route = plan.route; chick.targetIndex = 1; chick.nextScannerId = plan.nextScannerId || null; chick.queueRoute = null; chick.queueIndex = -1;
|
||||
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination));
|
||||
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination));
|
||||
return true;
|
||||
}
|
||||
function resolveMixer(index) {
|
||||
const chick = game.chicks[index]; if (!chick) return;
|
||||
const { x, y } = chick; game.chicks.splice(index, 1); game.stats.mixerCount += 1; game.totals.processed += 1;
|
||||
if (chick.sex === 'poop') { game.mixerHalfTimer = MIXER_HALF_SECONDS; game.stats.poopMixer += 1; game.totals.poopMixer += 1; game.stats.mistakes += 1; game.totals.mistakes += 1; sludgeEffect(game, x, y); floating(game, x, y - 18, `HALF PAY ${MIXER_HALF_SECONDS}s`, THEME.ink); return; }
|
||||
const amount = positivePayout(game, MIXER_PRICE); applyCashDelta(game, amount); if (chick.sex === 'male') { game.stats.correct += 1; game.totals.correct += 1; } else { game.stats.mistakes += 1; game.totals.mistakes += 1; } meatEffect(game, x, y); floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); uiSystem.checkGameOver();
|
||||
}
|
||||
function resolveTruck(index) {
|
||||
const chick = game.chicks[index]; if (!chick) return;
|
||||
const { x, y } = chick; game.chicks.splice(index, 1); addTruckCargo(chick.sex); truckLoadEffect(game, x, y, chick.sex); game.totals.processed += 1;
|
||||
const target = truckTarget(game);
|
||||
const isTarget = isTargetTruckCargo(game, chick.sex);
|
||||
if (chick.sex === 'poop') {
|
||||
game.stats.poopTruck += 1; game.totals.poopTruck += 1;
|
||||
if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); }
|
||||
else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `SOIL ${Math.round(truckPayoutMultiplier(game) * 100)}%`, THEME.ink); }
|
||||
return;
|
||||
}
|
||||
if (chick.sex === 'female') {
|
||||
game.stats.truckFemale += 1;
|
||||
if (isTarget) { const displayAmount = positivePayout(game, Math.floor(TRUCK_PRICE * truckPayoutMultiplier(game))); game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(displayAmount)}`, THEME.green); }
|
||||
else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, target === 'male' ? 'REJECT' : 'NO SALE', THEME.warn); }
|
||||
return;
|
||||
}
|
||||
game.stats.truckMale += 1;
|
||||
if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); return; }
|
||||
if (shouldFineMaleTruck(game)) { const penalty = maleTruckPenalty(game); applyCashDelta(game, -penalty); game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); uiSystem.checkGameOver(); }
|
||||
else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'REJECT', THEME.warn); }
|
||||
}
|
||||
function resolveTrash(index) {
|
||||
const chick = game.chicks[index]; if (!chick) return;
|
||||
const { x, y } = chick; game.chicks.splice(index, 1); shredEffect(game, x, y, chick.sex); game.stats.trashCount += 1; game.totals.processed += 1;
|
||||
if (chick.sex === 'poop') { game.stats.poopTrash += 1; game.totals.poopTrash += 1; game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, 'CLEAN', THEME.green); }
|
||||
else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'WASTE', THEME.ink); }
|
||||
}
|
||||
function removeChick(index, label) { const chick = game.chicks[index]; if (!chick) return; game.chicks.splice(index, 1); floating(game, chick.x, chick.y - 12, label, THEME.muted); }
|
||||
function addTruckCargo(sex) { const t = game.facilities.truck; if (!t) return; game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) }); if (game.truckCargo.length > 45) game.truckCargo.shift(); }
|
||||
|
||||
function conveyorKeyAtChick(chick) {
|
||||
const cell = pointToCell(chick.x, chick.y);
|
||||
if (cell) {
|
||||
const k = key(cell.col, cell.row);
|
||||
if (game.conveyorTiles.has(k)) return k;
|
||||
}
|
||||
// Fallback only when the chick is visually still on a conveyor center.
|
||||
const nearest = nearestConveyorKey(game, chick.x, chick.y);
|
||||
if (!nearest) return null;
|
||||
const p = parseKey(nearest);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
return Math.hypot(chick.x - c.x, chick.y - c.y) <= GRID.cell * 0.55 ? nearest : null;
|
||||
}
|
||||
|
||||
function updateCongestion() {
|
||||
const { components, cellToComponent } = buildConveyorComponents(game);
|
||||
for (const chick of game.chicks) {
|
||||
if (chick.stage === 'flying') continue;
|
||||
const k = conveyorKeyAtChick(chick);
|
||||
if (!k) continue;
|
||||
const id = cellToComponent.get(k);
|
||||
const comp = components.get(id);
|
||||
if (comp) comp.count += 1;
|
||||
}
|
||||
for (const comp of components.values()) comp.ratio = comp.count / comp.capacity;
|
||||
game.congestion = components;
|
||||
game.componentLookup = cellToComponent;
|
||||
}
|
||||
function checkCongestionExplosions(dt) {
|
||||
for (const comp of game.congestion.values()) {
|
||||
if (comp.ratio > 0.8 && comp.ratio < 1) {
|
||||
for (const chick of chicksInComponent(comp.id).slice(0, 3)) if (Math.random() < 0.06) rageEffect(game, chick.x, chick.y - chick.radius - 8);
|
||||
}
|
||||
if (comp.ratio >= 1) {
|
||||
const last = game.lastExplodedComponent.get(comp.id) || 0;
|
||||
const now = performance.now();
|
||||
if (now - last > 900) { game.lastExplodedComponent.set(comp.id, now); explodeComponent(comp); }
|
||||
}
|
||||
}
|
||||
}
|
||||
function chicksInComponent(id) {
|
||||
return game.chicks.filter(ch => { const k = conveyorKeyAtChick(ch); return k && game.componentLookup?.get(k) === id; });
|
||||
}
|
||||
function explodeComponent(comp) {
|
||||
const victims = chicksInComponent(comp.id);
|
||||
if (!victims.length) return;
|
||||
for (const v of victims) { flyingDebris(game, v.sex, v.x, v.y); }
|
||||
game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
|
||||
for (const scanner of game.scanners) scanner.queue = scanner.queue.filter(id => game.chicks.some(c => c.id === id));
|
||||
for (const k of comp.cells) { const p = parseKey(k); const c = cellCenter(p.col, p.row); shockwave(game, c.x, c.y, THEME.danger); sparkBurst(game, c.x, c.y, 6); smokeBurst(game, c.x, c.y, 4); }
|
||||
shake(game, 18, .75); const first = parseKey(comp.cells[0]); const fc = cellCenter(first.col, first.row); floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
|
||||
}
|
||||
function explodeRoute(route, extras, label) {
|
||||
const victims = game.chicks.filter(ch => route.some(p => Math.hypot(ch.x - p.x, ch.y - p.y) < GRID.cell));
|
||||
game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
|
||||
for (const v of victims) flyingDebris(game, v.sex, v.x, v.y);
|
||||
for (const e of extras) flyingDebris(game, e.sex, e.x, e.y);
|
||||
for (const p of route) { shockwave(game, p.x, p.y, THEME.danger); sparkBurst(game, p.x, p.y, 4); smokeBurst(game, p.x, p.y, 3); }
|
||||
shake(game, 18, .75); if (route[0]) floating(game, route[0].x, route[0].y - 18, label, THEME.danger);
|
||||
}
|
||||
|
||||
// Build/edit/selection operations live in src/systems/buildSystem.js.
|
||||
// HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js.
|
||||
// Hitbox generation lives in buildSystem.js.
|
||||
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 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 clickSelect(event) {
|
||||
const p = canvasPoint(event), hit = build.equipmentAtPoint(p);
|
||||
const sel = selectionForHit(hit);
|
||||
if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; }
|
||||
game.selected = sel;
|
||||
game.multiSelected = [sel];
|
||||
uiSystem.updatePanels();
|
||||
if (hit.type === 'scanner' && hit.ref.kind === 'auto') build.showAutoScannerMenu(hit.ref);
|
||||
}
|
||||
|
||||
canvas.addEventListener('contextmenu', event => event.preventDefault());
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
if (game.phase !== 'build') return;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const world = canvasPoint(event);
|
||||
if (event.button === 2) { startPan(event); return; }
|
||||
if (event.button !== 0) return;
|
||||
if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
|
||||
if (game.buildTool) { build.buildAtPoint(world); return; }
|
||||
if (build.startGroupDrag(event)) return;
|
||||
build.startSelectionBox(event);
|
||||
});
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (game.phase !== 'build') return;
|
||||
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 === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
|
||||
});
|
||||
canvas.addEventListener('pointerup', event => {
|
||||
if (game.selectionBox) build.finishSelectionBox();
|
||||
else if (game.groupDrag) {
|
||||
if (!game.groupDrag.committed) clickSelect(event);
|
||||
build.finishGroupDrag();
|
||||
}
|
||||
game.groupDrag = null; game.pan = null;
|
||||
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; });
|
||||
|
||||
ui.buttons.s1Left.addEventListener('click', () => sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => sortSlot(1, 'right'));
|
||||
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
|
||||
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); });
|
||||
window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } });
|
||||
|
||||
build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() });
|
||||
uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick });
|
||||
resetLayout(game); updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
|
||||
81
src/index.html
Normal file
81
src/index.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Chick Sorter v6.0 Visibility Pass</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="gameShell" class="game-shell" aria-label="Game screen">
|
||||
<canvas id="gameCanvas" width="1440" height="810" aria-label="Chick Sorter game canvas"></canvas>
|
||||
|
||||
<div class="hud hud-top-left" aria-label="Run status">
|
||||
<div class="hud-card cash"><span>CASH</span><strong id="money">¥140</strong></div>
|
||||
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
|
||||
<div class="hud-card profit"><span>PROFIT</span><strong id="turnProfit">¥0</strong></div>
|
||||
<div class="hud-card"><span>TURN</span><strong id="turn">1</strong></div>
|
||||
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="hud hud-top-right" aria-label="Turn result details">
|
||||
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
|
||||
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
|
||||
<div class="hud-card"><span>RANDOM</span><strong id="missedCount">0</strong></div>
|
||||
</div>
|
||||
|
||||
<div id="upgradePanel" class="upgrade-panel" aria-label="Upgrades and build tools">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v6.0</span></h1>
|
||||
<p>Industrial scanner junctions. Conveyor tiles can bend; scanners split to left/right output belts.</p>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Turn</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid">
|
||||
<section>
|
||||
<h2>Build</h2>
|
||||
<div class="tool-list">
|
||||
<button id="buildConveyorButton" class="tool-button" type="button">Conveyor Tile</button>
|
||||
<button id="buildEggFarmButton" class="tool-button" type="button">Egg Farm</button>
|
||||
<button id="moveScannerButton" class="tool-button" type="button">Scanner Table</button>
|
||||
<button id="eraseButton" class="tool-button danger" type="button">Erase Belt</button>
|
||||
</div>
|
||||
<div id="buildStatus" class="mini-box">Build tools unlock after each 60-second turn.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Upgrades</h2>
|
||||
<div id="upgradeList" class="upgrade-list"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Live Rules</h2>
|
||||
<div id="turnSummary" class="mini-box"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-dock" aria-label="Sorting controls">
|
||||
<button id="scanner1MixerButton" class="sort-button mixer" type="button">S1 A Mixer</button>
|
||||
<button id="scanner1TruckButton" class="sort-button truck" type="button">S1 D Truck</button>
|
||||
<button id="scanner2MixerButton" class="sort-button mixer" type="button">S2 ← Mixer</button>
|
||||
<button id="scanner2TruckButton" class="sort-button truck" type="button">S2 → Truck</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modal" class="modal visible">
|
||||
<div class="modal-card">
|
||||
<h2 id="modalTitle">Chick Sorter</h2>
|
||||
<div id="modalBody"></div>
|
||||
<div id="modalActions" class="modal-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./src/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
526
src/render/draw.js
Normal file
526
src/render/draw.js
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
|
||||
import { key, parseKey, cellCenter, mixHex, randomBetween } from '../core/utils.js';
|
||||
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor } from '../systems/routing.js';
|
||||
import { truckPayoutMultiplier } from '../systems/economy.js';
|
||||
|
||||
const ASSET_PATHS = {
|
||||
chickMale: './assets/images/chick_male.png',
|
||||
chickFemale: './assets/images/chick_female.png',
|
||||
poop: './assets/images/poop.png',
|
||||
conveyor: './assets/images/conveyor.png',
|
||||
scannerManual: './assets/images/scanner_manual.png',
|
||||
scannerAuto: './assets/images/scanner_auto.png',
|
||||
eggFarm: './assets/images/egg_farm.png',
|
||||
mixer: './assets/images/mixer.png',
|
||||
shredder: './assets/images/shredder.png',
|
||||
truck: './assets/images/truck.png'
|
||||
};
|
||||
|
||||
export const assets = {};
|
||||
for (const [name, src] of Object.entries(ASSET_PATHS)) {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.loaded = false;
|
||||
img.onload = () => { img.loaded = true; };
|
||||
assets[name] = img;
|
||||
}
|
||||
|
||||
// ART OVERLAY HOOK:
|
||||
// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS.
|
||||
// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes.
|
||||
// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI.
|
||||
|
||||
export function drawAll(ctx, canvas, game, helpers) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawBackground(ctx, canvas);
|
||||
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);
|
||||
drawGrid(ctx);
|
||||
drawConveyors(ctx, game);
|
||||
drawScannerConnectors(ctx, game);
|
||||
drawFacilities(ctx, game);
|
||||
drawEggFarms(ctx, game);
|
||||
drawScanners(ctx, game);
|
||||
drawChicks(ctx, game, helpers.activeQueuedChick);
|
||||
drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle);
|
||||
drawSelectionBox(ctx, game);
|
||||
drawEffects(ctx, game);
|
||||
drawFloatingTexts(ctx, game);
|
||||
if (game.debug) drawDebug(ctx, game);
|
||||
ctx.restore();
|
||||
drawCanvasHints(ctx, game, canvas);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
function drawImageIfLoaded(ctx, image, x, y, w, h) {
|
||||
if (!image?.loaded) return false;
|
||||
ctx.drawImage(image, x, y, w, h);
|
||||
return true;
|
||||
}
|
||||
function drawBackground(ctx, canvas) {
|
||||
ctx.fillStyle = THEME.bg;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(16, 32, 21, 0.035)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < canvas.width; x += 32) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
|
||||
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 drawGrid(ctx) {
|
||||
ctx.save();
|
||||
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 (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.restore();
|
||||
}
|
||||
function componentRatio(game, k) {
|
||||
const id = game.componentLookup?.get(k);
|
||||
const data = id ? game.congestion.get(id) : null;
|
||||
return data?.ratio || 0;
|
||||
}
|
||||
function drawConveyors(ctx, game) {
|
||||
const edges = [];
|
||||
const seen = new Set();
|
||||
for (const k of game.conveyorTiles) {
|
||||
const { col, row } = parseKey(k);
|
||||
for (const n of getConveyorNeighbors(game, col, row)) {
|
||||
const nk = key(n.col, n.row);
|
||||
const e = [k, nk].sort().join('|');
|
||||
if (seen.has(e)) continue;
|
||||
seen.add(e);
|
||||
const ratio = Math.max(componentRatio(game, k), componentRatio(game, nk));
|
||||
edges.push({ a: cellCenter(col, row), b: cellCenter(n.col, n.row), ratio });
|
||||
}
|
||||
}
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
for (const layer of [
|
||||
{ width: 33, color: THEME.ink, alpha: 1 },
|
||||
{ width: 25, color: THEME.white, alpha: 1 },
|
||||
{ width: 17, color: THEME.greenSoft, alpha: 1 }
|
||||
]) {
|
||||
ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
|
||||
for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
|
||||
}
|
||||
for (const e of edges) {
|
||||
const t = Math.max(0, (e.ratio - 0.5) / 0.5);
|
||||
ctx.strokeStyle = mixHex(THEME.green, THEME.danger, t);
|
||||
ctx.globalAlpha = e.ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
|
||||
}
|
||||
for (const k of game.conveyorTiles) {
|
||||
const c = cellCenter(...Object.values(parseKey(k)));
|
||||
const ratio = componentRatio(game, k);
|
||||
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
|
||||
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
|
||||
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
|
||||
}
|
||||
for (const farm of game.eggFarms) {
|
||||
const data = routeFromFarmToScanner(game, farm);
|
||||
if (data) drawRouteFlow(ctx, data.route, THEME.muted, 3);
|
||||
}
|
||||
const flowSegments = new Map();
|
||||
for (const scanner of game.scanners) {
|
||||
for (const side of ['left', 'right']) {
|
||||
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id);
|
||||
if (!plan) continue;
|
||||
addRouteFlowSegments(flowSegments, plan.route, plan.destination);
|
||||
}
|
||||
}
|
||||
drawDestinationFlowSegments(ctx, flowSegments);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawRouteFlow(ctx, points, color, width) {
|
||||
if (!points || points.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.globalAlpha = .78; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color; ctx.globalAlpha = .92;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i], b = points[i + 1];
|
||||
const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
|
||||
if (dist < 36) continue;
|
||||
drawArrow(ctx, a.x + dx * .55, a.y + dy * .55, Math.atan2(dy, dx));
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function flowSegmentKey(a, b) {
|
||||
const ak = `${Math.round(a.x)},${Math.round(a.y)}`;
|
||||
const bk = `${Math.round(b.x)},${Math.round(b.y)}`;
|
||||
return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`;
|
||||
}
|
||||
function addRouteFlowSegments(map, points, dest) {
|
||||
if (!points || points.length < 2) return;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i], b = points[i + 1];
|
||||
if (Math.hypot(b.x - a.x, b.y - a.y) < 8) continue;
|
||||
const k = flowSegmentKey(a, b);
|
||||
if (!map.has(k)) map.set(k, new Map());
|
||||
const dests = map.get(k);
|
||||
if (!dests.has(dest)) dests.set(dest, { a, b });
|
||||
}
|
||||
}
|
||||
function drawDestinationFlowSegments(ctx, segments) {
|
||||
const order = ['mixer', 'trash', 'truck', 'scanner'];
|
||||
for (const dests of segments.values()) {
|
||||
const entries = [...dests.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
|
||||
const count = entries.length;
|
||||
entries.forEach(([dest, seg], index) => {
|
||||
const offset = (index - (count - 1) / 2) * 8;
|
||||
drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset);
|
||||
});
|
||||
}
|
||||
}
|
||||
function drawColoredFlowSegment(ctx, a, b, color, offset) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < 8) return;
|
||||
const nx = -dy / dist, ny = dx / dist;
|
||||
const ax = a.x + nx * offset, ay = a.y + ny * offset;
|
||||
const bx = b.x + nx * offset, by = b.y + ny * offset;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.globalAlpha = .9; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.fillStyle = color; ctx.globalAlpha = .98;
|
||||
if (dist > 32) drawArrow(ctx, ax + (bx - ax) * .58, ay + (by - ay) * .58, Math.atan2(dy, dx));
|
||||
ctx.restore();
|
||||
}
|
||||
function drawArrow(ctx, x, y, angle) {
|
||||
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
|
||||
ctx.beginPath(); ctx.moveTo(11, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2, 0); ctx.lineTo(-6, 7); ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawScannerConnectors(ctx, game) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
for (const scanner of game.scanners) {
|
||||
const outputs = scannerOutputs(scanner);
|
||||
const items = [
|
||||
{ p: scannerConnector(scanner, 'inputA'), label: 'IN', color: THEME.green },
|
||||
{ p: scannerConnector(scanner, 'left'), label: destinationLabel(outputs.left), color: destinationColor(outputs.left) },
|
||||
{ p: scannerConnector(scanner, 'right'), label: destinationLabel(outputs.right), color: destinationColor(outputs.right) }
|
||||
];
|
||||
for (const item of items) {
|
||||
if (!item.p) continue;
|
||||
const p = cellCenter(item.p.col, item.p.row);
|
||||
const connected = game.conveyorTiles.has(key(item.p.col, item.p.row));
|
||||
ctx.fillStyle = connected ? item.color : '#f4c2c2';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true);
|
||||
ctx.fillStyle = connected && item.color === THEME.white ? THEME.ink : THEME.white;
|
||||
ctx.fillText(item.label, p.x, p.y + 4);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm(ctx, farm, game); }
|
||||
function drawEggFarm(ctx, farm, game) {
|
||||
const c = cellCenter(farm.col, farm.row);
|
||||
ctx.save();
|
||||
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { 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);
|
||||
const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
|
||||
if (shutter > 0) {
|
||||
const h = 56 * shutter;
|
||||
ctx.fillStyle = THEME.ink;
|
||||
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); }
|
||||
}
|
||||
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ctx, s, game); }
|
||||
function drawScanner(ctx, scanner, game) {
|
||||
const c = scannerCenter(scanner);
|
||||
ctx.save();
|
||||
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
|
||||
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; }
|
||||
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
|
||||
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.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 12);
|
||||
ctx.font = '900 10px ui-monospace, monospace';
|
||||
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 7);
|
||||
const q = scanner.queue.length;
|
||||
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
|
||||
ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25);
|
||||
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function targetForTruck(game) {
|
||||
return game.contractActive?.target || game.contractOffer?.target || 'female';
|
||||
}
|
||||
function drawItemIcon(ctx, x, y, type) {
|
||||
if (type === 'poop') drawTinyPoop(ctx, x, y);
|
||||
else drawTinyChick(ctx, x, y, type, false);
|
||||
}
|
||||
function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255,255,255,.92)';
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 3;
|
||||
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.textAlign = 'left';
|
||||
ctx.fillText(title, x + 34, y + 13);
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = THEME.muted;
|
||||
ctx.font = '900 8px ui-monospace, monospace';
|
||||
ctx.fillText(subtitle, x + 34, y + 25);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function receiverTitle(id, game) {
|
||||
if (id === 'mixer') return { title: 'IN: MALE', type: 'male', color: THEME.mixerBlue };
|
||||
if (id === 'trash') return { title: 'IN: POOP', type: 'poop', color: THEME.wasteGreen };
|
||||
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
|
||||
return { title: 'IN', type: 'female', color: THEME.green };
|
||||
}
|
||||
function drawFacilityReceiver(ctx, game, id) {
|
||||
const f = game.facilities[id];
|
||||
if (!f?.entry) return;
|
||||
const c = cellCenter(f.entry.col, f.entry.row);
|
||||
const info = receiverTitle(id, game);
|
||||
ctx.save();
|
||||
ctx.fillStyle = THEME.white;
|
||||
ctx.strokeStyle = info.color;
|
||||
ctx.lineWidth = 5;
|
||||
rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
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.textAlign = 'center';
|
||||
ctx.fillStyle = THEME.ink;
|
||||
ctx.fillText(info.title, c.x, c.y + 20);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawFacilities(ctx, game) {
|
||||
if (game.facilities.mixer) drawMixer(ctx, game);
|
||||
if (game.facilities.trash) drawTrash(ctx, game);
|
||||
if (game.facilities.truck) drawTruck(ctx, game);
|
||||
drawFacilityReceiver(ctx, game, 'mixer');
|
||||
drawFacilityReceiver(ctx, game, 'trash');
|
||||
drawFacilityReceiver(ctx, game, 'truck');
|
||||
}
|
||||
function drawExternalDuct(ctx, f) {
|
||||
if (!f?.entry) return;
|
||||
const c = cellCenter(f.entry.col, f.entry.row);
|
||||
let bx = f.x + f.w / 2, by = f.y + f.h / 2;
|
||||
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
|
||||
else if (f.side === 'right') { bx = f.x; by = c.y; }
|
||||
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
|
||||
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
|
||||
ctx.save();
|
||||
ctx.strokeStyle = THEME.ink;
|
||||
ctx.lineWidth = 9;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.strokeStyle = THEME.white; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function drawMixer(ctx, game) {
|
||||
const m = game.facilities.mixer;
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, m);
|
||||
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
|
||||
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(game.mixerHalfTimer > 0 ? `HALF PAY ${game.mixerHalfTimer.toFixed(1)}s` : `PAY ¥5`, m.x + m.w / 2, m.y + 48);
|
||||
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(); }
|
||||
drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawTrash(ctx, game) {
|
||||
const t = game.facilities.trash;
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, t);
|
||||
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`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');
|
||||
drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawTruck(ctx, game) {
|
||||
const t = game.facilities.truck;
|
||||
ctx.save();
|
||||
drawExternalDuct(ctx, t);
|
||||
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
|
||||
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
|
||||
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
|
||||
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`EST ${Math.round(truckPayoutMultiplier(game) * 100)}%`, t.x + t.w / 2, t.y + 42);
|
||||
const truckTargetType = targetForTruck(game);
|
||||
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
|
||||
ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
|
||||
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
|
||||
drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawChicks(ctx, game, activeQueuedChick) {
|
||||
const active1 = activeQueuedChick(0);
|
||||
const active2 = activeQueuedChick(1);
|
||||
for (const chick of game.chicks) {
|
||||
const active = (active1 && active1.id === chick.id) || (active2 && active2.id === chick.id) || chick.queueIndex === 0;
|
||||
drawChick(ctx, chick, active, game);
|
||||
}
|
||||
}
|
||||
function drawChick(ctx, chick, active, game) {
|
||||
const rage = game.componentLookup && nearestRatio(game, chick) > 0.8;
|
||||
const y = chick.y + Math.sin(chick.bob) * (rage ? 5 : 1.5);
|
||||
ctx.save();
|
||||
if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); }
|
||||
if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); }
|
||||
if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
|
||||
else {
|
||||
const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
|
||||
if (!drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2)) {
|
||||
ctx.fillStyle = chick.sex === 'male' ? THEME.male : THEME.female;
|
||||
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.arc(chick.x, y, chick.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = THEME.ink; ctx.beginPath(); ctx.arc(chick.x - 6, y - 5, 2.5, 0, Math.PI * 2); ctx.arc(chick.x + 6, y - 5, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function nearestRatio(game, chick) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const [k, id] of game.componentLookup.entries()) {
|
||||
const p = parseKey(k); const c = cellCenter(p.col, p.row); const d = Math.hypot(chick.x - c.x, chick.y - c.y);
|
||||
if (d < bestD) { bestD = d; best = id; }
|
||||
}
|
||||
return game.congestion.get(best)?.ratio || 0;
|
||||
}
|
||||
function drawPoop(ctx, x, y, radius) {
|
||||
if (drawImageIfLoaded(ctx, assets.poop, x - radius, y - radius, radius * 2, radius * 2)) return;
|
||||
ctx.save(); ctx.fillStyle = THEME.poop; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.arc(x, y + 7, radius * .86, Math.PI, 0, true); ctx.arc(x, y + 1, radius * .66, Math.PI, 0, true); ctx.arc(x, y - 5, radius * .44, Math.PI, 0, true); ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = THEME.white; ctx.beginPath(); ctx.arc(x - 5, y + 1, 2, 0, Math.PI * 2); ctx.arc(x + 5, y + 1, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore();
|
||||
}
|
||||
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); }
|
||||
ctx.restore();
|
||||
}
|
||||
function selectionToken(type, id) { return `${type}:${id}`; }
|
||||
function isSelected(game, type, id) {
|
||||
if (game.selected && game.selected.type === type && game.selected.id === id) return true;
|
||||
return (game.multiSelected || []).some(sel => selectionToken(sel.type, sel.id) === selectionToken(type, id));
|
||||
}
|
||||
function drawSelection(ctx, game, type, id, x, y, w, h) {
|
||||
if (!isSelected(game, type, id)) return;
|
||||
ctx.save(); ctx.strokeStyle = THEME.green; ctx.lineWidth = 4; ctx.setLineDash([8, 5]); ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.restore();
|
||||
}
|
||||
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();
|
||||
return;
|
||||
}
|
||||
const obj = selectedObject();
|
||||
if (!obj) return;
|
||||
let x = 80, y = 80;
|
||||
if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; }
|
||||
else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; }
|
||||
else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; }
|
||||
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore();
|
||||
}
|
||||
function drawSelectionBox(ctx, game) {
|
||||
if (game.phase !== 'build' || !game.selectionBox) return;
|
||||
const b = game.selectionBox;
|
||||
const x = Math.min(b.x1, b.x2), y = Math.min(b.y1, b.y2);
|
||||
const w = Math.abs(b.x2 - b.x1), h = Math.abs(b.y2 - b.y1);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(34,185,79,.12)';
|
||||
ctx.strokeStyle = THEME.green;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([7, 4]);
|
||||
rect(ctx, x, y, w, h, true, true);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawEffects(ctx, game) {
|
||||
const sorted = [...game.effects].sort((a, b) => (a.priority ?? EFFECT_PRIORITY.ambient) - (b.priority ?? EFFECT_PRIORITY.ambient));
|
||||
for (const e of sorted) {
|
||||
const alpha = Math.max(0, e.life / e.maxLife);
|
||||
ctx.save(); ctx.globalAlpha = alpha;
|
||||
if (e.type === 'meat') { ctx.fillStyle = THEME.green; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.5, e.size, true, false); }
|
||||
else if (e.type === 'sludge') { ctx.fillStyle = THEME.poop; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.4, e.size, true, false); }
|
||||
else if (e.type === 'spawn') { ctx.strokeStyle = THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 20, 0, Math.PI * 2); ctx.stroke(); }
|
||||
else if (e.type === 'spark') { ctx.fillStyle = e.color || THEME.danger; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, true, false); }
|
||||
else if (e.type === 'smoke') { ctx.fillStyle = 'rgba(16,32,21,.24)'; ctx.beginPath(); ctx.arc(e.x, e.y, e.size * (1.2 - alpha * .2), 0, Math.PI * 2); ctx.fill(); }
|
||||
else if (e.type === 'shockwave') { ctx.strokeStyle = e.color || THEME.danger; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * (e.maxSize || 44), 0, Math.PI * 2); ctx.stroke(); }
|
||||
else if (e.type === 'scannerPulse') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 5; ctx.setLineDash([8, 6]); ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 38, 0, Math.PI * 2); ctx.stroke(); }
|
||||
else if (e.type === 'shred') { ctx.fillStyle = e.color || THEME.ink; ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.rot || 0); rect(ctx, -e.w / 2, -e.h / 2, e.w, e.h, true, false); ctx.restore(); }
|
||||
else if (e.type === 'load') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(e.x - 14, e.y); ctx.lineTo(e.x + 14, e.y); ctx.moveTo(e.x, e.y - 14); ctx.lineTo(e.x, e.y + 14); ctx.stroke(); }
|
||||
else if (e.type === 'erase') { ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; ctx.setLineDash([4, 4]); rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, false, true); }
|
||||
else if (e.type === 'rage') { ctx.fillStyle = e.color || THEME.danger; ctx.font = '900 16px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', e.x, e.y); }
|
||||
else if (e.type === 'flyingChick') { e.sex === 'poop' ? drawPoop(ctx, e.x, e.y, e.radius || 11) : drawTinyChick(ctx, e.x, e.y, e.sex, e.sex === 'male'); }
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function drawFloatingTexts(ctx, game) {
|
||||
ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center';
|
||||
for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }
|
||||
ctx.restore();
|
||||
}
|
||||
function drawCanvasHints(ctx, game, canvas) {
|
||||
ctx.save();
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
|
||||
// Visible version marker helps avoid browser/file-cache confusion when testing zips.
|
||||
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
|
||||
rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
|
||||
ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
|
||||
ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
|
||||
const lines = [];
|
||||
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');
|
||||
if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect all receiver ports`);
|
||||
let y = 76;
|
||||
for (const line of lines) { ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; ctx.strokeText(line, 24, y); ctx.fillText(line, 24, y); y += 20; }
|
||||
ctx.restore();
|
||||
}
|
||||
function drawDebug(ctx, game) {
|
||||
ctx.save(); ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.danger;
|
||||
for (const k of game.conveyorTiles) { const { col, row } = parseKey(k); const c = cellCenter(col, row); const comp = game.componentLookup?.get(k) || '?'; ctx.fillText(`${col},${row}/C${comp}`, c.x, c.y + 24); }
|
||||
ctx.restore();
|
||||
}
|
||||
352
src/styles.css
Normal file
352
src/styles.css
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
:root {
|
||||
--bg: #020303;
|
||||
--screen: #050607;
|
||||
--panel: rgba(10, 12, 14, 0.94);
|
||||
--panel-solid: #0b0d0f;
|
||||
--panel-2: #111417;
|
||||
--line: rgba(132, 140, 148, 0.18);
|
||||
--line-strong: rgba(177, 185, 193, 0.62);
|
||||
--text: #d7dadd;
|
||||
--muted: #8d949b;
|
||||
--accent: #b4bac0;
|
||||
--accent-2: #b99561;
|
||||
--danger: #d66d6d;
|
||||
--good: #9fd27d;
|
||||
--shadow: 0 22px 70px rgba(0, 0, 0, 0.70);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.010) 1px, transparent 1px),
|
||||
#020303;
|
||||
background-size: 24px 24px;
|
||||
color: var(--text);
|
||||
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%;
|
||||
margin-top: 0;
|
||||
min-height: min(100vh, 920px);
|
||||
border: 1px solid #30363d;
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
background: var(--screen);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.035), transparent 90px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(calc(100vh - 10px), 900px);
|
||||
min-height: 660px;
|
||||
background: #050607;
|
||||
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(78px, auto));
|
||||
}
|
||||
.hud-top-right {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
grid-template-columns: repeat(4, minmax(78px, auto));
|
||||
}
|
||||
.hud-card {
|
||||
min-width: 78px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(82, 91, 100, 0.76);
|
||||
border-radius: 0;
|
||||
background: rgba(9, 11, 13, 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018), 0 8px 20px rgba(0, 0, 0, 0.26);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.hud-card span {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.09em;
|
||||
color: #7c858e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hud-card strong {
|
||||
font-size: clamp(15px, 1.35vw, 21px);
|
||||
line-height: 1;
|
||||
}
|
||||
.hud-card.cash strong { color: #d7b27d; }
|
||||
.hud-card.compact strong { font-size: clamp(13px, 1.1vw, 18px); }
|
||||
.hud-card.profit strong { color: var(--good); }
|
||||
|
||||
.cash-negative { color: var(--danger) !important; }
|
||||
.cash-positive { color: var(--good) !important; }
|
||||
|
||||
.upgrade-panel {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 64px;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border: 1px solid #343a40;
|
||||
border-radius: 0;
|
||||
background: rgba(8, 10, 12, 0.94);
|
||||
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.56);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
.panel-head h1 {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.panel-head h1 span {
|
||||
color: #050607;
|
||||
font-size: 11px;
|
||||
background: #b4bac0;
|
||||
border-radius: 0;
|
||||
padding: 2px 7px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.panel-head p,
|
||||
.mini-box,
|
||||
.upgrade-desc { color: var(--muted); }
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(420px, 1fr) 340px;
|
||||
gap: 10px;
|
||||
}
|
||||
.upgrade-panel h2 {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.10em;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.tool-list,
|
||||
.upgrade-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.tool-list { grid-template-columns: 1fr 1fr; }
|
||||
.tool-button,
|
||||
.primary-button,
|
||||
.sort-button,
|
||||
.buy-button {
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.035em;
|
||||
transition: transform 0.06s ease, filter 0.12s ease, opacity 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.tool-button:active,
|
||||
.primary-button:active,
|
||||
.sort-button:active,
|
||||
.buy-button:active { transform: translateY(1px); }
|
||||
.tool-button:disabled,
|
||||
.primary-button:disabled,
|
||||
.sort-button:disabled,
|
||||
.buy-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.38;
|
||||
}
|
||||
.tool-button {
|
||||
color: var(--text);
|
||||
background: #111417;
|
||||
border: 1px solid rgba(82, 91, 100, 0.76);
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
}
|
||||
.tool-button.active {
|
||||
border-color: #b4bac0;
|
||||
box-shadow: inset 0 0 0 1px rgba(180, 186, 192, 0.35);
|
||||
}
|
||||
.tool-button.danger.active {
|
||||
border-color: var(--danger);
|
||||
box-shadow: inset 0 0 0 1px rgba(214, 109, 109, 0.30);
|
||||
}
|
||||
.primary-button {
|
||||
border: 1px solid #b4bac0;
|
||||
padding: 9px 16px;
|
||||
background: #b4bac0;
|
||||
color: #050607;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upgrade-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.upgrade-item {
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 0;
|
||||
padding: 8px;
|
||||
background: #0f1215;
|
||||
}
|
||||
.upgrade-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upgrade-head strong { font-size: 12px; }
|
||||
.upgrade-level { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.upgrade-desc { font-size: 11px; line-height: 1.28; margin-bottom: 7px; }
|
||||
.buy-button {
|
||||
color: var(--text);
|
||||
background: #1a1f24;
|
||||
border: 1px solid #4b535b;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.buy-button:not(:disabled):hover,
|
||||
.tool-button:not(:disabled):hover,
|
||||
.sort-button:not(:disabled):hover { filter: brightness(1.14); }
|
||||
.mini-box {
|
||||
padding: 8px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 0;
|
||||
background: #080a0c;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mini-box strong { color: var(--text); }
|
||||
|
||||
.control-dock {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
left: 50%;
|
||||
bottom: 10px;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 150px);
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border: 1px solid #343a40;
|
||||
background: rgba(8, 10, 12, 0.92);
|
||||
}
|
||||
.sort-button {
|
||||
border: 1px solid #4b535b;
|
||||
min-height: 46px;
|
||||
font-size: 12px;
|
||||
color: #f0f1f2;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025);
|
||||
}
|
||||
.sort-button.mixer {
|
||||
background: #1b2025;
|
||||
}
|
||||
.sort-button.truck {
|
||||
background: #2a2118;
|
||||
border-color: #705a3c;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 22px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
.modal.visible { display: flex; }
|
||||
.modal-card {
|
||||
width: min(720px, 96vw);
|
||||
border: 1px solid #4b535b;
|
||||
border-radius: 0;
|
||||
padding: 22px;
|
||||
background: #090b0d;
|
||||
box-shadow: 0 34px 110px rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
.modal-card h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.modal-card p { color: var(--text); line-height: 1.55; }
|
||||
.modal-card ul {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 22px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.modal-card strong { color: var(--text); }
|
||||
.modal-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#app { padding: 0; }
|
||||
.game-shell { min-height: 100vh; }
|
||||
#gameCanvas { height: 100vh; min-height: 680px; }
|
||||
.hud-top-left,
|
||||
.hud-top-right { grid-template-columns: repeat(2, minmax(74px, auto)); }
|
||||
.panel-grid { grid-template-columns: 1fr; }
|
||||
.upgrade-list { grid-template-columns: 1fr; }
|
||||
.upgrade-panel { max-height: 44vh; overflow: auto; }
|
||||
.control-dock { grid-template-columns: repeat(2, 150px); }
|
||||
}
|
||||
|
||||
/* v6 visibility pass: play-first layering */
|
||||
.game-shell:not(.phase-build) .upgrade-panel {
|
||||
display: none;
|
||||
}
|
||||
.game-shell.phase-running .hud-top-right {
|
||||
display: none;
|
||||
}
|
||||
.game-shell:not(.phase-running) .control-dock {
|
||||
display: none;
|
||||
}
|
||||
.game-shell.phase-build .upgrade-panel {
|
||||
bottom: 10px;
|
||||
}
|
||||
.game-shell.phase-running .hud-card {
|
||||
background: rgba(5, 7, 8, 0.84);
|
||||
border-color: rgba(124, 133, 142, 0.42);
|
||||
}
|
||||
542
src/systems/buildSystem.js
Normal file
542
src/systems/buildSystem.js
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from '../core/config.js';
|
||||
import { nextSpawnDelay, getSpawnRange } from '../core/state.js';
|
||||
import { createEggFarm, createScanner, createFacility, nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
|
||||
import { key, parseKey, clamp, pointToCell, cellCenter, distance, yen } from '../core/utils.js';
|
||||
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner } from './routing.js';
|
||||
import { spendCash, refundCash } from './economy.js';
|
||||
import { snapshot, record } from './history.js';
|
||||
import { floating, eraseEffect } from './effects.js';
|
||||
|
||||
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 rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
|
||||
function rectsOverlap(a, b, margin = 10) {
|
||||
return !(a.x + a.w + margin <= b.x || b.x + b.w + margin <= a.x || a.y + a.h + margin <= b.y || b.y + b.h + margin <= a.y);
|
||||
}
|
||||
function facilityOverlaps(candidate, ignoreIds = new Set()) {
|
||||
const rect = rectOfFacility(candidate);
|
||||
return Object.values(game.facilities).some(f => !ignoreIds.has(f.id) && rectsOverlap(rect, rectOfFacility(f), 12));
|
||||
}
|
||||
|
||||
function isEquipmentCell(col, row, moving = null) {
|
||||
if (!pointInGrid(col, row)) return false;
|
||||
const k = key(col, row);
|
||||
const existingFarm = farmAt(game, col, row);
|
||||
if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
|
||||
const existingScanner = scannerAt(game, col, row);
|
||||
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
|
||||
if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectedObject() {
|
||||
if (!game.selected) return null;
|
||||
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
|
||||
if (game.selected.type === 'scanner') return game.scanners.find(s => s.id === game.selected.id) || null;
|
||||
if (game.selected.type === 'conveyor') return game.conveyorTiles.has(game.selected.id) ? { type: 'conveyor', id: game.selected.id } : null;
|
||||
if (game.selected.type === 'facility') return game.facilities[game.selected.id] || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectedTitle(obj) {
|
||||
if (!obj) return 'Equipment';
|
||||
if (obj.type === 'eggFarm') return `Egg Farm #${obj.id}`;
|
||||
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? 'Auto' : 'Manual'} Scanner #${obj.id}`;
|
||||
if (obj.type === 'conveyor') return 'Conveyor Tile';
|
||||
if (obj.type === 'facility') return obj.name;
|
||||
return 'Equipment';
|
||||
}
|
||||
|
||||
function equipmentPrice(hit) {
|
||||
const obj = hit.ref || hit;
|
||||
const type = hit.type || obj.type;
|
||||
if (type === 'conveyor') return BUILD_COSTS.conveyor;
|
||||
if (type === 'eggFarm') return obj.price || BUILD_COSTS.eggFarm;
|
||||
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner);
|
||||
if (type === 'facility') return obj.price || FACILITY_PRICES[obj.id] || 300;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
ui.buildStatus.textContent = reason;
|
||||
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger);
|
||||
}
|
||||
|
||||
function buildAtCell(cell) {
|
||||
if (!cell) return fail('Out of grid');
|
||||
const { col, row } = cell;
|
||||
if (game.buildTool === 'conveyor') {
|
||||
if (game.conveyorTiles.has(key(col, row))) return fail('Cell occupied');
|
||||
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail('Cell occupied');
|
||||
if (game.cash < BUILD_COSTS.conveyor) return fail('Not enough cash');
|
||||
record(game);
|
||||
spendCash(game, BUILD_COSTS.conveyor);
|
||||
const k = key(col, row);
|
||||
game.conveyorTiles.add(k);
|
||||
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: game.buildSession });
|
||||
game.selected = { type: 'conveyor', id: k };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.conveyor)}`, THEME.ink);
|
||||
return;
|
||||
}
|
||||
if (isEquipmentCell(col, row)) return fail('Cell occupied');
|
||||
if (game.buildTool === 'eggFarm') buildFarm(col, row);
|
||||
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
|
||||
else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto');
|
||||
}
|
||||
|
||||
function buildAtPoint(p) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
|
||||
if (['mixer', 'trash', 'truck'].includes(game.buildTool)) return buildFacility(p, game.buildTool);
|
||||
}
|
||||
|
||||
function buildFarm(col, row) {
|
||||
if (game.cash < BUILD_COSTS.eggFarm) return fail('Not enough cash');
|
||||
record(game);
|
||||
spendCash(game, BUILD_COSTS.eggFarm);
|
||||
const farm = createEggFarm(game, col, row);
|
||||
game.eggFarms.push(farm);
|
||||
game.selected = { type: 'eggFarm', id: farm.id };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.eggFarm)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function buildScanner(col, row, kind) {
|
||||
const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
const scanner = createScanner(game, col, row, kind);
|
||||
game.scanners.push(scanner);
|
||||
game.selected = { type: 'scanner', id: scanner.id };
|
||||
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function buildFacility(p, id) {
|
||||
const cost = FACILITY_PRICES[id];
|
||||
if (game.facilities[id]) return fail('Facility already exists');
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
const f = createFacility(game, id, p, cost);
|
||||
if (facilityOverlaps(f)) return fail('Facility overlap');
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
game.facilities[id] = f;
|
||||
game.selected = { type: 'facility', id };
|
||||
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
|
||||
}
|
||||
|
||||
function refundForSameBuild(meta, x, y) {
|
||||
if (!meta || meta.builtSession !== game.buildSession) return;
|
||||
const amount = meta.price || 0;
|
||||
if (amount > 0) {
|
||||
refundCash(game, amount);
|
||||
floating(game, x, y - 16, `REFUND +${yen(amount)}`, THEME.green);
|
||||
}
|
||||
}
|
||||
|
||||
function eraseAtPoint(p) {
|
||||
const hit = equipmentAtPoint(p);
|
||||
if (!hit) return fail('Nothing to erase');
|
||||
record(game);
|
||||
if (hit.type === 'conveyor') {
|
||||
const c = parseKey(hit.oldKey);
|
||||
const center = cellCenter(c.col, c.row);
|
||||
refundForSameBuild(game.conveyorMeta.get(hit.oldKey), center.x, center.y);
|
||||
game.conveyorTiles.delete(hit.oldKey);
|
||||
game.conveyorMeta.delete(hit.oldKey);
|
||||
eraseEffect(game, center.x, center.y);
|
||||
}
|
||||
if (hit.type === 'eggFarm') {
|
||||
const c = cellCenter(hit.ref.col, hit.ref.row);
|
||||
refundForSameBuild(hit.ref, c.x, c.y);
|
||||
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
}
|
||||
if (hit.type === 'scanner') {
|
||||
const c = scannerCenter(hit.ref);
|
||||
refundForSameBuild(hit.ref, c.x, c.y);
|
||||
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
}
|
||||
if (hit.type === 'facility') {
|
||||
const f = hit.ref;
|
||||
refundForSameBuild(f, f.x + f.w / 2, f.y + f.h / 2);
|
||||
delete game.facilities[f.id];
|
||||
eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2);
|
||||
}
|
||||
game.selected = null;
|
||||
game.multiSelected = [];
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function equipmentAtPoint(p) {
|
||||
const f = Object.values(game.facilities).find(x => {
|
||||
const inBody = p.x >= x.x && p.x <= x.x + x.w && p.y >= x.y && p.y <= x.y + x.h;
|
||||
if (inBody) return true;
|
||||
if (!x.entry) return false;
|
||||
const c = cellCenter(x.entry.col, x.entry.row);
|
||||
return Math.abs(p.x - c.x) <= GRID.cell * 0.46 && Math.abs(p.y - c.y) <= GRID.cell * 0.46;
|
||||
});
|
||||
if (f) return { type: 'facility', ref: f };
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (!cell) return null;
|
||||
const farm = farmAt(game, cell.col, cell.row);
|
||||
if (farm) return { type: 'eggFarm', ref: farm };
|
||||
const scanner = scannerAt(game, cell.col, cell.row);
|
||||
if (scanner) return { type: 'scanner', ref: scanner };
|
||||
const k = key(cell.col, cell.row);
|
||||
if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } };
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectionToken(item) {
|
||||
if (!item) return '';
|
||||
if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`;
|
||||
if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`;
|
||||
return `${item.type}:${item.id || item.ref?.id}`;
|
||||
}
|
||||
|
||||
function selectionFromHit(hit) {
|
||||
if (!hit) return null;
|
||||
if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey };
|
||||
return { type: hit.type, id: hit.ref.id };
|
||||
}
|
||||
|
||||
function hitIsMultiSelected(hit) {
|
||||
const token = selectionToken(selectionFromHit(hit));
|
||||
return (game.multiSelected || []).some(sel => selectionToken(sel) === token);
|
||||
}
|
||||
|
||||
function resolveSelection(sel) {
|
||||
if (!sel) return null;
|
||||
if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null;
|
||||
if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null;
|
||||
if (sel.type === 'facility') return game.facilities[sel.id] || null;
|
||||
if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function equipmentItems() {
|
||||
const items = [];
|
||||
for (const k of game.conveyorTiles) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
items.push({ type: 'conveyor', id: k, center: c, cell: p });
|
||||
}
|
||||
for (const farm of game.eggFarms) items.push({ type: 'eggFarm', id: farm.id, ref: farm, center: cellCenter(farm.col, farm.row), cell: { col: farm.col, row: farm.row } });
|
||||
for (const scanner of game.scanners) items.push({ type: 'scanner', id: scanner.id, ref: scanner, center: scannerCenter(scanner), cell: { col: scanner.col, row: scanner.row } });
|
||||
for (const f of Object.values(game.facilities)) {
|
||||
const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter;
|
||||
items.push({ type: 'facility', id: f.id, ref: f, center: bodyCenter, portCenter });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pointInsideRect(p, rect) {
|
||||
const x1 = Math.min(rect.x1, rect.x2), x2 = Math.max(rect.x1, rect.x2);
|
||||
const y1 = Math.min(rect.y1, rect.y2), y2 = Math.max(rect.y1, rect.y2);
|
||||
return p.x >= x1 && p.x <= x2 && p.y >= y1 && p.y <= y2;
|
||||
}
|
||||
|
||||
function selectInRect(rect) {
|
||||
const selected = [];
|
||||
for (const item of equipmentItems()) {
|
||||
if (pointInsideRect(item.center, rect) || (item.portCenter && pointInsideRect(item.portCenter, rect))) {
|
||||
selected.push({ type: item.type, id: item.id });
|
||||
}
|
||||
}
|
||||
game.multiSelected = selected;
|
||||
game.selected = selected[0] || null;
|
||||
updatePanels();
|
||||
return selected.length;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
game.multiSelected = [];
|
||||
game.selected = null;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function startSelectionBox(event) {
|
||||
const p = canvasPoint(event);
|
||||
game.selectionBox = { x1: p.x, y1: p.y, x2: p.x, y2: p.y };
|
||||
game.multiSelected = [];
|
||||
game.selected = null;
|
||||
updatePanels();
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSelectionBox(event) {
|
||||
if (!game.selectionBox) return;
|
||||
const p = canvasPoint(event);
|
||||
game.selectionBox.x2 = p.x;
|
||||
game.selectionBox.y2 = p.y;
|
||||
}
|
||||
|
||||
function finishSelectionBox() {
|
||||
if (!game.selectionBox) return 0;
|
||||
const box = game.selectionBox;
|
||||
const w = Math.abs(box.x2 - box.x1), h = Math.abs(box.y2 - box.y1);
|
||||
game.selectionBox = null;
|
||||
if (w < 6 && h < 6) { clearSelection(); return 0; }
|
||||
return selectInRect(box);
|
||||
}
|
||||
|
||||
function selectedSetForDrag(hit) {
|
||||
const fromHit = selectionFromHit(hit);
|
||||
if (!fromHit) return [];
|
||||
if ((game.multiSelected || []).length && hitIsMultiSelected(hit)) return [...game.multiSelected];
|
||||
return [fromHit];
|
||||
}
|
||||
|
||||
function selectionOrigin(sel) {
|
||||
const obj = resolveSelection(sel);
|
||||
if (!obj) return null;
|
||||
if (sel.type === 'conveyor') {
|
||||
const cell = parseKey(sel.id);
|
||||
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: BUILD_COSTS.conveyor, builtSession: null }, col: cell.col, row: cell.row };
|
||||
}
|
||||
if (sel.type === 'eggFarm' || sel.type === 'scanner') return { ...sel, obj, col: obj.col, row: obj.row };
|
||||
if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
|
||||
return null;
|
||||
}
|
||||
|
||||
function startGroupDrag(event) {
|
||||
const p = canvasPoint(event);
|
||||
const hit = equipmentAtPoint(p);
|
||||
if (!hit) return false;
|
||||
const selections = selectedSetForDrag(hit);
|
||||
const origins = selections.map(selectionOrigin).filter(Boolean);
|
||||
if (!origins.length) return false;
|
||||
game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
|
||||
game.multiSelected = selections;
|
||||
game.selected = selections[0] || null;
|
||||
updatePanels();
|
||||
return true;
|
||||
}
|
||||
|
||||
function commitGroupDragHistory() {
|
||||
if (!game.groupDrag || game.groupDrag.committed) return;
|
||||
game.undoStack.push(game.groupDrag.historySnapshot);
|
||||
game.redoStack = [];
|
||||
game.groupDrag.committed = true;
|
||||
}
|
||||
|
||||
function cellOccupiedByNonSelected(col, row, selectedTokens) {
|
||||
const k = key(col, row);
|
||||
const farm = farmAt(game, col, row);
|
||||
if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
|
||||
const scanner = scannerAt(game, col, row);
|
||||
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
|
||||
if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function facilityDragWouldOverlap(dx, dy, selectedTokens) {
|
||||
const candidateRects = [];
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
if (origin.type !== 'facility') continue;
|
||||
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
|
||||
const { entry, side } = nearestGridEdge(nextCenter);
|
||||
const draft = { ...origin.obj, entry: { ...entry }, side };
|
||||
layoutFacilityOnEdge(draft, entry, side);
|
||||
candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) });
|
||||
}
|
||||
for (const c of candidateRects) {
|
||||
for (const f of Object.values(game.facilities)) {
|
||||
if (selectedTokens.has(`facility:${f.id}`)) continue;
|
||||
if (rectsOverlap(c.rect, rectOfFacility(f), 12)) return true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < candidateRects.length; i += 1) {
|
||||
for (let j = i + 1; j < candidateRects.length; j += 1) {
|
||||
if (rectsOverlap(candidateRects[i].rect, candidateRects[j].rect, 12)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateGroupDrag(event) {
|
||||
if (!game.groupDrag) return;
|
||||
const p = canvasPoint(event);
|
||||
const dx = p.x - game.groupDrag.start.x;
|
||||
const dy = p.y - game.groupDrag.start.y;
|
||||
if (Math.hypot(dx, dy) < 3) return;
|
||||
const dcol = Math.round(dx / GRID.cell);
|
||||
const drow = Math.round(dy / GRID.cell);
|
||||
const selectedTokens = new Set(game.groupDrag.selections.map(selectionToken));
|
||||
const targetCells = new Set();
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
if (origin.type === 'facility') continue;
|
||||
const col = origin.col + dcol, row = origin.row + drow;
|
||||
if (!pointInGrid(col, row)) return fail('Selection outside grid');
|
||||
const tk = key(col, row);
|
||||
if (targetCells.has(tk)) return fail('Selection overlap');
|
||||
if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
|
||||
targetCells.add(tk);
|
||||
}
|
||||
if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
|
||||
commitGroupDragHistory();
|
||||
|
||||
const conveyorOrigins = game.groupDrag.origins.filter(o => o.type === 'conveyor');
|
||||
for (const o of conveyorOrigins) {
|
||||
const current = o.currentKey || o.oldKey;
|
||||
game.conveyorTiles.delete(current);
|
||||
game.conveyorMeta.delete(current);
|
||||
}
|
||||
for (const o of conveyorOrigins) {
|
||||
const newK = key(o.col + dcol, o.row + drow);
|
||||
game.conveyorTiles.add(newK);
|
||||
game.conveyorMeta.set(newK, o.meta || { price: BUILD_COSTS.conveyor, builtSession: null });
|
||||
o.currentKey = newK;
|
||||
}
|
||||
for (const origin of game.groupDrag.origins) {
|
||||
if (origin.type === 'eggFarm') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; }
|
||||
else if (origin.type === 'scanner') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; }
|
||||
else if (origin.type === 'facility') {
|
||||
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
|
||||
const { entry, side } = nearestGridEdge(nextCenter);
|
||||
layoutFacilityOnEdge(origin.obj, entry, side);
|
||||
}
|
||||
}
|
||||
game.groupDrag.selections = game.groupDrag.origins.map(o => o.type === 'conveyor' ? { type: 'conveyor', id: o.currentKey || o.oldKey } : { type: o.type, id: o.id });
|
||||
game.multiSelected = [...game.groupDrag.selections];
|
||||
game.selected = game.multiSelected[0] || null;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function finishGroupDrag() {
|
||||
if (!game.groupDrag) return;
|
||||
game.groupDrag = null;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function selectedUpgradeCost(obj) {
|
||||
if (!obj || obj.type === 'conveyor') return null;
|
||||
if (obj.type === 'eggFarm') return [0, 220, 640, 1500][obj.level] || null;
|
||||
if (obj.type === 'scanner') return [0, 260, 720, 1600][obj.level] || null;
|
||||
if (obj.type === 'facility') return [0, 300, 900][obj.level] || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function upgradeSelected() {
|
||||
const obj = selectedObject();
|
||||
const cost = selectedUpgradeCost(obj);
|
||||
if (!cost) return fail('Max level or no upgrade');
|
||||
if (game.cash < cost) return fail('Not enough cash');
|
||||
record(game);
|
||||
spendCash(game, cost);
|
||||
obj.level += 1;
|
||||
if (obj.type === 'eggFarm') {
|
||||
obj.nextSpawn = nextSpawnDelay(obj);
|
||||
obj.lastInterval = obj.nextSpawn;
|
||||
}
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
const obj = selectedObject();
|
||||
if (!obj) return;
|
||||
if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id))));
|
||||
else if (obj.type === 'eggFarm') eraseAtPoint(cellCenter(obj.col, obj.row));
|
||||
else if (obj.type === 'scanner') eraseAtPoint(scannerCenter(obj));
|
||||
else if (obj.type === 'facility') eraseAtPoint({ x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 });
|
||||
}
|
||||
|
||||
function modalButton(text, onClick, className = 'primary-button') {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = className;
|
||||
b.textContent = text;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function hideModal() { ui.modal.classList.remove('visible'); }
|
||||
|
||||
function showAutoScannerMenu(scanner) {
|
||||
ui.modalTitle.textContent = 'Auto Scanner Configuration';
|
||||
ui.modalBody.innerHTML = `<p>Auto scanner types will be split later. Current type is <strong>Standard</strong>.</p><ul><li>Role 0: male left, everything else right.</li><li>Role 1: poop left, everything else right.</li><li>Cooldown: ${AUTO_SCANNER_COOLDOWN}s.</li></ul>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
|
||||
const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
|
||||
const close = modalButton('Close', hideModal);
|
||||
ui.modalActions.append(r0, r1, close);
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function switchScannerRole() {
|
||||
const obj = selectedObject();
|
||||
if (!obj || obj.type !== 'scanner') return;
|
||||
if (obj.kind === 'auto') return showAutoScannerMenu(obj);
|
||||
record(game);
|
||||
obj.role = obj.role === 0 ? 1 : 0;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function setBuildTool(tool) {
|
||||
if (game.phase !== 'build') return;
|
||||
game.buildTool = game.buildTool === tool ? null : tool;
|
||||
game.selected = null;
|
||||
game.multiSelected = [];
|
||||
game.selectionBox = null;
|
||||
updatePanels();
|
||||
}
|
||||
|
||||
function equipmentHitBoxes() {
|
||||
const boxes = [];
|
||||
for (const [id, f] of Object.entries(game.facilities)) {
|
||||
boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h });
|
||||
if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); }
|
||||
}
|
||||
for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
|
||||
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
|
||||
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: BUILD_COSTS.conveyor, x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
|
||||
return boxes;
|
||||
}
|
||||
|
||||
function selectedInfoLines(obj) {
|
||||
const lines = [];
|
||||
if (obj.type === 'eggFarm') {
|
||||
const [min, max] = getSpawnRange(obj);
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Level: ${obj.level}/4`);
|
||||
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
||||
} else if (obj.type === 'scanner') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
|
||||
lines.push(`Role: ${obj.role === 0 ? 'Male→Left / Others→Right' : 'Poop→Left / Others→Right'}`);
|
||||
lines.push(`Queue: ${obj.queue.length}`);
|
||||
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`);
|
||||
} else if (obj.type === 'conveyor') {
|
||||
const meta = game.conveyorMeta.get(obj.id);
|
||||
const comp = game.componentLookup?.get(obj.id);
|
||||
const c = comp ? game.congestion.get(comp) : null;
|
||||
lines.push(`Price: ${yen(BUILD_COSTS.conveyor)}`);
|
||||
lines.push(`Cell: ${obj.id}`);
|
||||
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
|
||||
lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
|
||||
if (meta?.builtSession === game.buildSession) lines.push(`Same-build refund: ${yen(meta.price)}`);
|
||||
} else if (obj.type === 'facility') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
lines.push(`Level: ${obj.level}/3`);
|
||||
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint,
|
||||
startSelectionBox, updateSelectionBox, finishSelectionBox,
|
||||
startGroupDrag, updateGroupDrag, finishGroupDrag,
|
||||
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
|
||||
selectedInfoLines,
|
||||
upgradeSelected, removeSelected, switchScannerRole,
|
||||
showAutoScannerMenu, setBuildTool, fail,
|
||||
isEquipmentCell, equipmentHitBoxes,
|
||||
routeFromFarmToScanner
|
||||
};
|
||||
}
|
||||
148
src/systems/contracts.js
Normal file
148
src/systems/contracts.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TURN } from '../core/config.js';
|
||||
import { getSpawnRange } from '../core/state.js';
|
||||
|
||||
export const CONTRACT_TARGETS = [
|
||||
{
|
||||
target: 'female',
|
||||
title: 'Premium Female Shipment',
|
||||
route: 'Female → Truck',
|
||||
note: 'Only female chicks are valid truck cargo this turn.'
|
||||
},
|
||||
{
|
||||
target: 'male',
|
||||
title: 'Male Export Trial',
|
||||
route: 'Male → Truck',
|
||||
note: 'Male chicks are temporarily valid truck cargo. Male truck fines are suspended for valid male cargo.'
|
||||
},
|
||||
{
|
||||
target: 'poop',
|
||||
title: 'Contaminant Sample Export',
|
||||
route: 'Poop → Truck',
|
||||
note: 'Poop is temporarily valid truck cargo. Shipped poop does not soil this contract shipment.'
|
||||
}
|
||||
];
|
||||
|
||||
export function factoryValue(game) {
|
||||
return [
|
||||
...game.eggFarms.map(f => f.price || 0),
|
||||
...game.scanners.map(s => s.price || 0),
|
||||
...Object.values(game.facilities).map(f => f.price || 0),
|
||||
...[...game.conveyorMeta.values()].map(m => m.price || 0)
|
||||
].reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function estimatedChicksPerTurn(game, productionMultiplier = 1) {
|
||||
return game.eggFarms.reduce((sum, farm) => {
|
||||
const [min, max] = getSpawnRange(farm);
|
||||
const avg = Math.max(0.1, (min + max) / 2);
|
||||
return sum + (TURN_SECONDS / avg) * productionMultiplier;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function rollContractOffer(game) {
|
||||
// No irregular events during the first seven running turns.
|
||||
// Offers created after Turn 7 apply to Turn 8 or later.
|
||||
const nextTurn = game.turn + 1;
|
||||
if (nextTurn < CONTRACT_EVENT_FIRST_TURN) return null;
|
||||
if (Math.random() > CONTRACT_EVENT_CHANCE) return null;
|
||||
const template = CONTRACT_TARGETS[Math.floor(Math.random() * CONTRACT_TARGETS.length)];
|
||||
const productionMultiplier = 2;
|
||||
const poopMultiplier = 3;
|
||||
const estimate = estimatedChicksPerTurn(game, productionMultiplier);
|
||||
const targetAmount = Math.max(1, Math.ceil(estimate * 0.9));
|
||||
const rate = Math.round((0.08 + Math.random() * 0.08) * 100) / 100;
|
||||
const rewardByFactory = Math.floor(factoryValue(game) * rate);
|
||||
const rewardByCash = Math.floor(Math.max(0, game.cash) * 0.25);
|
||||
const reward = Math.max(rewardByFactory, rewardByCash);
|
||||
return {
|
||||
id: `contract-${game.turn}-${Date.now()}-${Math.floor(Math.random() * 9999)}`,
|
||||
...template,
|
||||
forced: true,
|
||||
productionMultiplier,
|
||||
poopMultiplier,
|
||||
targetAmount,
|
||||
estimatedOutput: Math.round(estimate),
|
||||
rewardRate: rate,
|
||||
reward,
|
||||
rewardByFactory,
|
||||
rewardByCash,
|
||||
createdTurn: game.turn,
|
||||
oneTurnOnly: true
|
||||
};
|
||||
}
|
||||
|
||||
export function acceptContract(game) { return !!game.contractOffer; }
|
||||
|
||||
export function declineContract(game) { return false; }
|
||||
|
||||
export function activateAcceptedContract(game) {
|
||||
// v10.1: limited events are forced. If an event is shown during Build phase,
|
||||
// it automatically applies to the next turn; there is no accept/skip step.
|
||||
game.contractActive = game.contractOffer ? { ...game.contractOffer } : null;
|
||||
game.contractOffer = null;
|
||||
}
|
||||
|
||||
export function clearActiveContract(game) {
|
||||
game.contractActive = null;
|
||||
}
|
||||
|
||||
export function productionMultiplier(game) {
|
||||
return game.contractActive?.productionMultiplier || 1;
|
||||
}
|
||||
|
||||
export function currentPoopRate(game) {
|
||||
return Math.min(0.9, POOP_RATE * (game.contractActive?.poopMultiplier || 1));
|
||||
}
|
||||
|
||||
export function truckTarget(game) {
|
||||
return game.contractActive?.target || 'female';
|
||||
}
|
||||
|
||||
export function targetTruckCount(stats, target) {
|
||||
if (target === 'male') return stats.truckMale || 0;
|
||||
if (target === 'poop') return stats.poopTruck || 0;
|
||||
return stats.truckFemale || 0;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export function resolveContract(game, applyCashDelta) {
|
||||
const contract = game.contractActive;
|
||||
if (!contract) return null;
|
||||
const count = targetTruckCount(game.stats, contract.target);
|
||||
const success = count >= contract.targetAmount;
|
||||
const bonus = success ? contract.reward : 0;
|
||||
if (bonus > 0) {
|
||||
applyCashDelta(game, bonus);
|
||||
game.stats.contractBonus = bonus;
|
||||
game.totals.contractBonus += bonus;
|
||||
game.totals.contractSuccess += 1;
|
||||
} else {
|
||||
game.stats.contractBonus = 0;
|
||||
game.totals.contractFailed += 1;
|
||||
}
|
||||
const result = {
|
||||
title: contract.title,
|
||||
target: contract.target,
|
||||
route: contract.route,
|
||||
targetAmount: contract.targetAmount,
|
||||
count,
|
||||
success,
|
||||
bonus,
|
||||
reward: contract.reward,
|
||||
productionMultiplier: contract.productionMultiplier,
|
||||
poopMultiplier: contract.poopMultiplier
|
||||
};
|
||||
game.stats.contractResult = result;
|
||||
return result;
|
||||
}
|
||||
59
src/systems/economy.js
Normal file
59
src/systems/economy.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { MIXER_PRICE, TRUCK_PRICE, TRUCK_POOP_DECAY, MIXER_HALF_SECONDS } from '../core/config.js';
|
||||
import { factoryValue, targetTruckCount, truckTarget, isPoopSoilActive } from './contracts.js';
|
||||
|
||||
export function truckPayoutMultiplier(game) {
|
||||
return Math.max(0, 1 - TRUCK_POOP_DECAY * (isPoopSoilActive(game) ? game.stats.poopTruck : 0));
|
||||
}
|
||||
export function positivePayout(game, amount) {
|
||||
const base = Math.max(0, Math.floor(amount));
|
||||
return game.mixerHalfTimer > 0 ? Math.floor(base / 2) : base;
|
||||
}
|
||||
export function maleTruckPenalty(game) {
|
||||
return Math.floor(30 * (game.turn / 2));
|
||||
}
|
||||
export function applyCashDelta(game, delta) {
|
||||
game.cash += delta;
|
||||
game.stats.profit += delta;
|
||||
game.totals.profit += delta;
|
||||
if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; }
|
||||
else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); }
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
}
|
||||
export function spendCash(game, amount) {
|
||||
game.cash -= amount;
|
||||
game.stats.profit -= amount;
|
||||
game.totals.profit -= amount;
|
||||
game.stats.penalty += amount;
|
||||
game.totals.penalty += amount;
|
||||
}
|
||||
export function refundCash(game, amount) {
|
||||
if (amount <= 0) return;
|
||||
game.cash += amount;
|
||||
game.stats.profit += amount;
|
||||
game.totals.profit += amount;
|
||||
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
|
||||
}
|
||||
export function settleTruckRevenue(game) {
|
||||
const target = truckTarget(game);
|
||||
const targetCount = targetTruckCount(game.stats, target);
|
||||
const base = targetCount * TRUCK_PRICE;
|
||||
const soilMultiplier = truckPayoutMultiplier(game);
|
||||
const adjusted = positivePayout(game, Math.floor(base * soilMultiplier));
|
||||
game.stats.pendingTruckRevenue = adjusted;
|
||||
if (adjusted > 0) applyCashDelta(game, adjusted);
|
||||
return { base, soilMultiplier, adjusted, target, targetCount };
|
||||
}
|
||||
export function finalScore(game) {
|
||||
const value = factoryValue(game);
|
||||
const score = Math.max(0, Math.floor(
|
||||
game.cash +
|
||||
game.totals.revenue +
|
||||
value * 0.5 +
|
||||
game.totals.correct * 5 +
|
||||
game.totals.contractBonus * 0.5 -
|
||||
game.totals.explosionDamage * 2 -
|
||||
game.totals.penalty
|
||||
));
|
||||
return { score, factoryValue: value };
|
||||
}
|
||||
export { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS };
|
||||
93
src/systems/effects.js
vendored
Normal file
93
src/systems/effects.js
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { EFFECT_PRIORITY, THEME } from '../core/config.js';
|
||||
import { randomBetween } from '../core/utils.js';
|
||||
import { applyCashDelta } from './economy.js';
|
||||
|
||||
export function floating(game, x, y, text, color = THEME.ink) {
|
||||
game.floatingTexts.push({ x, y, text, color, life: 1, maxLife: 1 });
|
||||
}
|
||||
export function shake(game, strength, time) {
|
||||
game.shake.strength = Math.max(game.shake.strength, strength);
|
||||
game.shake.time = Math.max(game.shake.time, time);
|
||||
}
|
||||
export function spawnPulse(game, x, y) {
|
||||
game.effects.push({ type: 'spawn', priority: EFFECT_PRIORITY.ambient, x, y, life: .42, maxLife: .42, size: 12 });
|
||||
}
|
||||
export function scannerPulse(game, x, y, color = THEME.green) {
|
||||
game.effects.push({ type: 'scannerPulse', priority: EFFECT_PRIORITY.important, x, y, color, life: .42, maxLife: .42, size: 24 });
|
||||
}
|
||||
export function meatEffect(game, x, y) {
|
||||
for (let i = 0; i < 12; i += 1) game.effects.push({ type: 'meat', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-60, 60), vy: randomBetween(-90, -25), life: randomBetween(.45, .85), maxLife: .85, size: randomBetween(4, 8) });
|
||||
}
|
||||
export function sludgeEffect(game, x, y) {
|
||||
for (let i = 0; i < 16; i += 1) game.effects.push({ type: 'sludge', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-46, 46), vy: randomBetween(-70, -20), life: randomBetween(.45, .9), maxLife: .9, size: randomBetween(4, 8) });
|
||||
}
|
||||
export function shredEffect(game, x, y, sex) {
|
||||
const base = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.male : THEME.female);
|
||||
for (let i = 0; i < 22; i += 1) game.effects.push({ type: 'shred', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-16, 16), y: y + randomBetween(-8, 8), vx: randomBetween(-70, 70), vy: randomBetween(40, 160), color: i % 3 === 0 ? THEME.white : base, life: randomBetween(.45, .95), maxLife: .95, w: randomBetween(3, 7), h: randomBetween(10, 24), rot: randomBetween(-Math.PI, Math.PI) });
|
||||
shockwave(game, x, y, THEME.green, 34, EFFECT_PRIORITY.important);
|
||||
}
|
||||
export function truckLoadEffect(game, x, y, sex) {
|
||||
const color = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.danger : THEME.green);
|
||||
for (let i = 0; i < 8; i += 1) game.effects.push({ type: 'load', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-18, 18), vx: randomBetween(-42, 42), vy: randomBetween(-90, -25), color, life: randomBetween(.35, .65), maxLife: .65, size: 8 });
|
||||
}
|
||||
export function shockwave(game, x, y, color = THEME.danger, maxSize = 52, priority = EFFECT_PRIORITY.critical) {
|
||||
game.effects.push({ type: 'shockwave', priority, x, y, color, life: .55, maxLife: .55, size: 8, maxSize });
|
||||
}
|
||||
export function sparkBurst(game, x, y, count = 12, color = THEME.danger, priority = EFFECT_PRIORITY.critical) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
game.effects.push({ type: 'spark', priority, x, y, vx: randomBetween(-260, 260), vy: randomBetween(-270, 120), life: randomBetween(.35, .9), maxLife: .9, size: randomBetween(3, 8), color });
|
||||
}
|
||||
}
|
||||
export function smokeBurst(game, x, y, count = 10) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) });
|
||||
}
|
||||
}
|
||||
export function flyingDebris(game, sex, x, y, baseAngle = null) {
|
||||
const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9);
|
||||
const speed = randomBetween(260, 620);
|
||||
game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8) });
|
||||
}
|
||||
export function eraseEffect(game, x, y) {
|
||||
game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 });
|
||||
sparkBurst(game, x, y, 8, THEME.ink, EFFECT_PRIORITY.important);
|
||||
}
|
||||
export function rageEffect(game, x, y) {
|
||||
game.effects.push({ type: 'rage', priority: EFFECT_PRIORITY.important, x, y, vx: randomBetween(-12, 12), vy: randomBetween(-45, -10), life: .45, maxLife: .45, size: randomBetween(7, 12), color: THEME.danger });
|
||||
}
|
||||
export function updateEffects(game, dt, canvas, equipmentHitBoxes) {
|
||||
if (game.shake.time > 0) {
|
||||
game.shake.time = Math.max(0, game.shake.time - dt);
|
||||
if (game.shake.time <= 0) game.shake.strength = 0;
|
||||
}
|
||||
for (const e of game.effects) {
|
||||
e.life -= dt;
|
||||
if (['meat', 'sludge', 'spark', 'smoke', 'shred', 'load', 'rage'].includes(e.type)) {
|
||||
e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt;
|
||||
} else if (e.type === 'flyingChick') {
|
||||
e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt;
|
||||
handleFlyingChickDamage(game, e, equipmentHitBoxes);
|
||||
const sx = e.x + game.view.x, sy = e.y + game.view.y;
|
||||
if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0;
|
||||
}
|
||||
}
|
||||
game.effects = game.effects.filter(e => e.life > 0);
|
||||
for (const t of game.floatingTexts) { t.life -= dt; t.y -= 34 * dt; }
|
||||
game.floatingTexts = game.floatingTexts.filter(t => t.life > 0);
|
||||
}
|
||||
function handleFlyingChickDamage(game, e, equipmentHitBoxes) {
|
||||
if (!e.hit) e.hit = new Set();
|
||||
for (const box of equipmentHitBoxes()) {
|
||||
if (e.hit.has(box.id)) continue;
|
||||
if (e.x < box.x || e.x > box.x + box.w || e.y < box.y || e.y > box.y + box.h) continue;
|
||||
e.hit.add(box.id);
|
||||
const damage = Math.max(1, Math.floor(box.price / 15));
|
||||
applyCashDelta(game, -damage);
|
||||
game.stats.explosionDamage += damage;
|
||||
game.totals.explosionDamage += damage;
|
||||
floating(game, e.x, e.y - 10, `-${damage}`, THEME.danger);
|
||||
shockwave(game, e.x, e.y, THEME.danger, 30);
|
||||
sparkBurst(game, e.x, e.y, 5, THEME.danger);
|
||||
shake(game, 5, 0.18);
|
||||
}
|
||||
}
|
||||
59
src/systems/history.js
Normal file
59
src/systems/history.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export function cleanScanner(scanner) {
|
||||
return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 };
|
||||
}
|
||||
export function snapshot(game) {
|
||||
return JSON.stringify({
|
||||
cash: game.cash,
|
||||
stats: game.stats,
|
||||
totals: game.totals,
|
||||
nextId: game.nextId,
|
||||
selected: game.selected,
|
||||
multiSelected: game.multiSelected || [],
|
||||
buildTool: game.buildTool,
|
||||
view: game.view,
|
||||
facilities: game.facilities,
|
||||
eggFarms: game.eggFarms,
|
||||
scanners: game.scanners.map(cleanScanner),
|
||||
conveyorTiles: [...game.conveyorTiles],
|
||||
conveyorMeta: [...game.conveyorMeta.entries()],
|
||||
branchCounters: [...game.branchCounters.entries()]
|
||||
});
|
||||
}
|
||||
export function restore(game, text) {
|
||||
const data = JSON.parse(text);
|
||||
game.cash = data.cash;
|
||||
game.stats = data.stats;
|
||||
game.totals = data.totals;
|
||||
game.nextId = data.nextId;
|
||||
game.selected = data.selected;
|
||||
game.multiSelected = data.multiSelected || [];
|
||||
game.buildTool = data.buildTool;
|
||||
game.view = data.view || { x: 0, y: 0 };
|
||||
game.facilities = data.facilities || {};
|
||||
game.eggFarms = data.eggFarms || [];
|
||||
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));
|
||||
game.conveyorTiles = new Set(data.conveyorTiles || []);
|
||||
game.conveyorMeta = new Map(data.conveyorMeta || []);
|
||||
game.branchCounters = new Map(data.branchCounters || []);
|
||||
game.groupDrag = null;
|
||||
game.selectionBox = null;
|
||||
game.pan = null;
|
||||
}
|
||||
export function record(game) {
|
||||
if (game.phase !== 'build') return;
|
||||
game.undoStack.push(snapshot(game));
|
||||
if (game.undoStack.length > 80) game.undoStack.shift();
|
||||
game.redoStack = [];
|
||||
}
|
||||
export function undo(game) {
|
||||
if (game.phase !== 'build' || !game.undoStack.length) return false;
|
||||
game.redoStack.push(snapshot(game));
|
||||
restore(game, game.undoStack.pop());
|
||||
return true;
|
||||
}
|
||||
export function redo(game) {
|
||||
if (game.phase !== 'build' || !game.redoStack.length) return false;
|
||||
game.undoStack.push(snapshot(game));
|
||||
restore(game, game.redoStack.pop());
|
||||
return true;
|
||||
}
|
||||
270
src/systems/routing.js
Normal file
270
src/systems/routing.js
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { DIRS, GRID } from '../core/config.js';
|
||||
import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js';
|
||||
|
||||
export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); }
|
||||
export function scannerConnector(scanner, type) {
|
||||
return {
|
||||
inputA: { col: scanner.col, row: scanner.row - 1 },
|
||||
// Bottom input is intentionally disabled. Scanners now have one input on top
|
||||
// and two outputs on the left/right, which makes junction direction easier to read.
|
||||
inputB: null,
|
||||
left: { col: scanner.col - 1, row: scanner.row },
|
||||
right: { col: scanner.col + 1, row: scanner.row }
|
||||
}[type];
|
||||
}
|
||||
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
|
||||
export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; }
|
||||
export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; }
|
||||
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
|
||||
|
||||
export function getConveyorNeighbors(game, col, row) {
|
||||
const result = [];
|
||||
for (const d of DIRS) {
|
||||
const nc = col + d.dc, nr = row + d.dr;
|
||||
if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function getAdjacentConveyors(game, col, row) {
|
||||
return DIRS.map(d => ({ col: col + d.dc, row: row + d.dr }))
|
||||
.filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
|
||||
}
|
||||
export function getInputScanner(game, col, row) {
|
||||
return game.scanners.find(scanner => {
|
||||
const a = scannerConnector(scanner, 'inputA');
|
||||
return a && sameCell({ col, row }, a);
|
||||
}) || null;
|
||||
}
|
||||
export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); }
|
||||
export function isOutputConnector(game, col, row) {
|
||||
return game.scanners.some(scanner => {
|
||||
const l = scannerConnector(scanner, 'left');
|
||||
const r = scannerConnector(scanner, 'right');
|
||||
return sameCell({ col, row }, l) || sameCell({ col, row }, r);
|
||||
});
|
||||
}
|
||||
export function inputCellsForScanner(game, scanner) {
|
||||
return ['inputA'].map(t => scannerConnector(scanner, t))
|
||||
.filter(p => p && inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
|
||||
}
|
||||
export function routePoints(cells, extraEnd = null) {
|
||||
const pts = cells.map(p => cellCenter(p.col, p.row));
|
||||
if (extraEnd) pts.push(extraEnd);
|
||||
return pts;
|
||||
}
|
||||
function dirBetween(a, b) {
|
||||
const dc = b.col - a.col, dr = b.row - a.row;
|
||||
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
|
||||
}
|
||||
function isCross(game, col, row) {
|
||||
const ns = getConveyorNeighbors(game, col, row);
|
||||
const names = new Set(ns.map(n => n.dir.name));
|
||||
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
|
||||
}
|
||||
|
||||
// Routing rule: a four-way cross conveyor is an overpass/crossing. Chicks never turn there.
|
||||
// The BFS state includes the incoming direction so every route that enters a cross must leave straight.
|
||||
export function bfsAllRoutes(game, start, isGoal) {
|
||||
const startK = key(start.col, start.row);
|
||||
if (!game.conveyorTiles.has(startK)) return [];
|
||||
const startState = `${startK}|none`;
|
||||
const queue = [{ col: start.col, row: start.row, incoming: 'none' }];
|
||||
const visited = new Set([startState]);
|
||||
const parent = new Map();
|
||||
const found = [];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
const curK = key(cur.col, cur.row);
|
||||
const stateK = `${curK}|${cur.incoming}`;
|
||||
if (isGoal(cur)) found.push(stateK);
|
||||
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
|
||||
const outDir = dirBetween(cur, n);
|
||||
if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue;
|
||||
const nextStateK = `${key(n.col, n.row)}|${outDir}`;
|
||||
if (visited.has(nextStateK)) continue;
|
||||
visited.add(nextStateK);
|
||||
parent.set(nextStateK, stateK);
|
||||
queue.push({ col: n.col, row: n.row, incoming: outDir });
|
||||
}
|
||||
}
|
||||
return found.map(goalState => {
|
||||
const reversed = [];
|
||||
let cursor = goalState;
|
||||
while (cursor) {
|
||||
const [cellK] = cursor.split('|');
|
||||
reversed.push(parseKey(cellK));
|
||||
if (cellK === startK) break;
|
||||
cursor = parent.get(cursor);
|
||||
}
|
||||
return reversed.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
export function chooseRoundRobin(game, id, candidates, advance = false) {
|
||||
if (!candidates.length) return null;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const ka = a.key || JSON.stringify(a.route || a.cells || a);
|
||||
const kb = b.key || JSON.stringify(b.route || b.cells || b);
|
||||
return ka.localeCompare(kb);
|
||||
});
|
||||
const n = game.branchCounters.get(id) || 0;
|
||||
if (advance) game.branchCounters.set(id, n + 1);
|
||||
return sorted[n % sorted.length];
|
||||
}
|
||||
|
||||
export function scannerOutputs(scanner) {
|
||||
if (scanner.role === 0) return { left: 'mixer', right: 'scanner-role-1' };
|
||||
return { left: 'trash', right: 'truck' };
|
||||
}
|
||||
export function destinationLabel(dest) {
|
||||
return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase();
|
||||
}
|
||||
export function destinationColor(dest) {
|
||||
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
|
||||
}
|
||||
|
||||
export function facilityEntryPoint(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f) return null;
|
||||
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
|
||||
// Legacy fallback for older save data. New facilities use fixed receiver cells on the grid edge.
|
||||
if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
|
||||
if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
|
||||
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
|
||||
}
|
||||
export function facilityEntryCell(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
return f?.entry || null;
|
||||
}
|
||||
|
||||
export function facilityEndpointCells(game, dest) {
|
||||
const f = game.facilities[dest];
|
||||
if (!f?.entry) return [];
|
||||
const entry = f.entry;
|
||||
const exactKey = key(entry.col, entry.row);
|
||||
if (game.conveyorTiles.has(exactKey)) return [{ ...entry }];
|
||||
// v10.4 connection tolerance: if the receiver port was moved to the grid edge
|
||||
// but the player left the belt one cell next to it, treat that adjacent belt as
|
||||
// the endpoint. This keeps moved scanners/conveyors/edge ports from appearing
|
||||
// visually correct but failing to connect.
|
||||
const adjacent = [];
|
||||
for (const d of DIRS) {
|
||||
const p = { col: entry.col + d.dc, row: entry.row + d.dr };
|
||||
if (inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))) adjacent.push(p);
|
||||
}
|
||||
return adjacent;
|
||||
}
|
||||
|
||||
export function isFacilityEndpoint(game, p, dest, connector) {
|
||||
if (sameCell(p, connector)) return false;
|
||||
const f = game.facilities[dest];
|
||||
// New facilities only route into their explicit receiver port, or one adjacent
|
||||
// belt tile when the receiver itself has no tile. Arbitrary dead ends never
|
||||
// auto-become machine exits.
|
||||
if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
|
||||
const degree = getConveyorNeighbors(game, p.col, p.row).length;
|
||||
if (degree > 1) return false;
|
||||
const entry = facilityEntryPoint(game, dest);
|
||||
if (!entry) return false;
|
||||
return distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
|
||||
}
|
||||
|
||||
export function routeFromFarmToScanner(game, farm, advance = false) {
|
||||
const starts = getAdjacentConveyors(game, farm.col, farm.row).filter(p => !isOutputConnector(game, p.col, p.row));
|
||||
const candidates = [];
|
||||
for (const start of starts) {
|
||||
const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
|
||||
for (const cells of routes) {
|
||||
const last = cells[cells.length - 1];
|
||||
const scanner = getInputScanner(game, last.col, last.row);
|
||||
if (!scanner) continue;
|
||||
candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
|
||||
}
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) };
|
||||
}
|
||||
|
||||
export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
|
||||
const scanner = scannerById(game, scannerId);
|
||||
if (!scanner) return null;
|
||||
const connector = scannerConnector(scanner, side);
|
||||
if (!connector || !game.conveyorTiles.has(key(connector.col, connector.row))) return null;
|
||||
const dest = scannerOutputs(scanner)[side];
|
||||
if (dest === 'scanner-role-1') {
|
||||
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
|
||||
const candidates = [];
|
||||
for (const target of targets) {
|
||||
const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row)));
|
||||
const routes = bfsAllRoutes(game, connector, p => inputKeys.has(key(p.col, p.row)));
|
||||
for (const cells of routes) candidates.push({ key: `s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
|
||||
}
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { destination: 'scanner', nextScannerId: chosen.target.id, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, scannerCenter(chosen.target))] };
|
||||
}
|
||||
const routes = bfsAllRoutes(game, connector, p => isFacilityEndpoint(game, p, dest, connector));
|
||||
const candidates = routes.map(cells => ({ key: `${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells }));
|
||||
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
|
||||
if (!chosen) return null;
|
||||
return { destination: dest, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, facilityEntryPoint(game, dest))] };
|
||||
}
|
||||
|
||||
export function facilityConnectionIssues(game) {
|
||||
const issues = [];
|
||||
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
|
||||
for (const id of ['mixer', 'trash', 'truck']) {
|
||||
const f = game.facilities[id];
|
||||
if (!f) {
|
||||
issues.push(`${labels[id]} is missing`);
|
||||
continue;
|
||||
}
|
||||
const endpoints = facilityEndpointCells(game, id);
|
||||
if (!endpoints.length) issues.push(`${labels[id]} receiver has no conveyor`);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function factoryReady(game) {
|
||||
return facilityConnectionIssues(game).length === 0;
|
||||
}
|
||||
|
||||
export function buildConveyorComponents(game) {
|
||||
const components = new Map();
|
||||
const cellToComponent = new Map();
|
||||
let next = 1;
|
||||
for (const k of game.conveyorTiles) {
|
||||
if (cellToComponent.has(k)) continue;
|
||||
const id = next++;
|
||||
const queue = [parseKey(k)];
|
||||
const cells = [];
|
||||
cellToComponent.set(k, id);
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
const ck = key(cur.col, cur.row);
|
||||
cells.push(ck);
|
||||
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
|
||||
const nk = key(n.col, n.row);
|
||||
if (cellToComponent.has(nk)) continue;
|
||||
cellToComponent.set(nk, id);
|
||||
queue.push({ col: n.col, row: n.row });
|
||||
}
|
||||
}
|
||||
components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
|
||||
}
|
||||
return { components, cellToComponent };
|
||||
}
|
||||
|
||||
export function nearestConveyorKey(game, x, y) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const k of game.conveyorTiles) {
|
||||
const p = parseKey(k);
|
||||
const c = cellCenter(p.col, p.row);
|
||||
const d = Math.hypot(c.x - x, c.y - y);
|
||||
if (d < bestD) { bestD = d; best = k; }
|
||||
}
|
||||
return bestD <= GRID.cell * 1.1 ? best : null;
|
||||
}
|
||||
225
src/systems/uiSystem.js
Normal file
225
src/systems/uiSystem.js
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, CONTRACT_EVENT_FIRST_TURN, STARTING_CASH, VERSION } from '../core/config.js';
|
||||
import { yen } from '../core/utils.js';
|
||||
import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js';
|
||||
import { MIXER_PRICE, TRUCK_PRICE, maleTruckPenalty, truckPayoutMultiplier, finalScore } from './economy.js';
|
||||
import { productionMultiplier, truckTarget, targetTruckCount, factoryValue } from './contracts.js';
|
||||
|
||||
export function createUISystem({ game, ui, build, startGame, activeQueuedChick }) {
|
||||
function conveyorSpeedForTurn(turn) { return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1)); }
|
||||
function displaySpeed() { return Math.round(conveyorSpeedForTurn(game.phase === 'build' ? game.turn + 1 : game.turn)); }
|
||||
|
||||
function updatePanels() {
|
||||
updateToolButtons();
|
||||
updateBuildStatus();
|
||||
updateFacilityPanel();
|
||||
updateContractPanel();
|
||||
updateTurnSummary();
|
||||
updateHistoryButtons();
|
||||
}
|
||||
|
||||
function updateToolButtons() {
|
||||
for (const [name, btn] of Object.entries(ui.buttons)) {
|
||||
if (btn?.classList?.contains('tool-button')) btn.classList.toggle('active', game.buildTool === name || (name === 'trash' && game.buildTool === 'trash'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateBuildStatus() {
|
||||
ui.buildStatus.textContent = game.phase === 'build'
|
||||
? `BUILD: ${(game.buildTool || 'SELECT').toUpperCase()} | Right-drag pan | Left-drag select/move`
|
||||
: 'Sorting: use scanner keys. Build after clearing the turn.';
|
||||
}
|
||||
|
||||
function updateHistoryButtons() {
|
||||
ui.buttons.undo.disabled = game.phase !== 'build' || game.undoStack.length === 0;
|
||||
ui.buttons.redo.disabled = game.phase !== 'build' || game.redoStack.length === 0;
|
||||
}
|
||||
|
||||
function updateTurnSummary() {
|
||||
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length;
|
||||
const issues = facilityConnectionIssues(game);
|
||||
ui.turnSummary.innerHTML = [
|
||||
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
|
||||
`Belt: <b>${displaySpeed()}px/s</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
|
||||
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">All receiver ports connected.</span>`
|
||||
].join('<br>');
|
||||
}
|
||||
|
||||
function updateContractPanel() {
|
||||
if (!ui.contractPanel) return;
|
||||
const offer = game.contractOffer;
|
||||
if (game.phase !== 'build') {
|
||||
ui.contractPanel.innerHTML = 'Forced events appear in Build phase.';
|
||||
return;
|
||||
}
|
||||
if (!offer) {
|
||||
ui.contractPanel.className = 'contract-card empty';
|
||||
const nextTurn = game.turn + 1;
|
||||
if (nextTurn < CONTRACT_EVENT_FIRST_TURN) {
|
||||
ui.contractPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Turns 1–7. First possible forced event: Turn ${CONTRACT_EVENT_FIRST_TURN}.</span>`;
|
||||
} else {
|
||||
ui.contractPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
ui.contractPanel.className = 'contract-card accepted';
|
||||
ui.contractPanel.innerHTML = `
|
||||
<div class="contract-status">FORCED NEXT TURN</div>
|
||||
<h3>${offer.title}</h3>
|
||||
<div class="contract-metrics">
|
||||
<span>Route: ${offer.route}</span>
|
||||
<span>Target: ${offer.targetAmount} truck exports</span>
|
||||
<span>Reward: ${yen(offer.reward)}</span>
|
||||
<span>Output ×${offer.productionMultiplier} / Poop ×${offer.poopMultiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateFacilityPanel() {
|
||||
const obj = build.selectedObject();
|
||||
if (!obj) {
|
||||
ui.facilityPanel.className = 'facility-panel-empty';
|
||||
ui.facilityPanel.innerHTML = 'Click equipment to upgrade, remove, or move it.';
|
||||
return;
|
||||
}
|
||||
ui.facilityPanel.className = 'facility-card';
|
||||
const lines = build.selectedInfoLines(obj);
|
||||
if (obj.type === 'facility' && obj.id === 'truck') lines.push(`Current truck multiplier: ${Math.round(truckPayoutMultiplier(game) * 100)}%`);
|
||||
if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.');
|
||||
|
||||
const actions = [];
|
||||
const cost = build.selectedUpgradeCost(obj);
|
||||
if (cost) actions.push(`<button class="facility-action good" data-action="upgrade">Upgrade ${yen(cost)}</button>`);
|
||||
if (obj.type === 'scanner') actions.push(`<button class="facility-action warn" data-action="role">${obj.kind === 'auto' ? 'Change Auto Menu' : 'Switch Role'}</button>`);
|
||||
const refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession;
|
||||
actions.push(`<button class="facility-action danger" data-action="remove">Remove${refundable ? ' / Refund' : ''}</button>`);
|
||||
|
||||
ui.facilityPanel.innerHTML = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<div class="facility-actions">${actions.join('')}</div>`;
|
||||
ui.facilityPanel.querySelector('[data-action="upgrade"]')?.addEventListener('click', build.upgradeSelected);
|
||||
ui.facilityPanel.querySelector('[data-action="role"]')?.addEventListener('click', build.switchScannerRole);
|
||||
ui.facilityPanel.querySelector('[data-action="remove"]')?.addEventListener('click', build.removeSelected);
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
ui.shell.classList.toggle('phase-running', game.phase === 'running');
|
||||
ui.shell.classList.toggle('phase-build', game.phase === 'build');
|
||||
ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover');
|
||||
ui.shell.classList.toggle('phase-title', game.phase === 'title');
|
||||
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);
|
||||
ui.turn.textContent = game.turn;
|
||||
ui.timeLeft.textContent = `${Math.max(0, game.timeLeft).toFixed(1)}s`;
|
||||
ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && game.timeLeft <= 10);
|
||||
ui.phase.textContent = phaseLabel();
|
||||
updatePriorityStrip();
|
||||
ui.turnProfit.textContent = yen(game.stats.profit);
|
||||
ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
|
||||
ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0);
|
||||
ui.mixerCount.textContent = game.stats.mixerCount;
|
||||
ui.truckFemaleCount.textContent = game.stats.truckFemale;
|
||||
ui.truckMaleCount.textContent = game.stats.truckMale;
|
||||
ui.poopCount.textContent = game.stats.poopSpawned;
|
||||
ui.buttons.s1Left.disabled = game.phase !== 'running' || !activeQueuedChick(0);
|
||||
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);
|
||||
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game);
|
||||
}
|
||||
|
||||
function updatePriorityStrip() {
|
||||
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.title = 'Base speed rises by 2% at every new turn.';
|
||||
const active = game.contractActive;
|
||||
const offer = game.contractOffer;
|
||||
if (active) {
|
||||
ui.eventBrief.textContent = `ACTIVE EVENT: ${active.route} | Output ×${active.productionMultiplier} | Poop ×${active.poopMultiplier}`;
|
||||
ui.eventBrief.className = 'event-brief active';
|
||||
} else if (game.phase === 'build' && offer) {
|
||||
ui.eventBrief.textContent = `NEXT TURN EVENT: ${offer.route} | Target ${offer.targetAmount} | Reward ${yen(offer.reward)}`;
|
||||
ui.eventBrief.className = 'event-brief next';
|
||||
} else if (game.phase === 'build' && game.turn + 1 < CONTRACT_EVENT_FIRST_TURN) {
|
||||
ui.eventBrief.textContent = `NO EVENTS UNTIL TURN ${CONTRACT_EVENT_FIRST_TURN}`;
|
||||
ui.eventBrief.className = 'event-brief locked';
|
||||
} else {
|
||||
ui.eventBrief.textContent = 'NO ACTIVE EVENT';
|
||||
ui.eventBrief.className = 'event-brief';
|
||||
}
|
||||
}
|
||||
|
||||
function phaseLabel() {
|
||||
if (game.phase === 'running') return game.timeLeft <= 0 ? 'Clearing' : 'Sorting';
|
||||
if (game.phase === 'build') return 'Build';
|
||||
if (game.phase === 'gameover') return 'Game Over';
|
||||
return 'Title';
|
||||
}
|
||||
|
||||
function showTitle() {
|
||||
ui.modalTitle.textContent = `Chick Sorter ${VERSION}`;
|
||||
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon">🐤</span><strong>Male</strong><b>→</b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon">🐣</span><strong>Female</strong><b>→</b><em>Truck</em><small>S1: D, then S2: →</small></div><div class="route-card"><span class="route-icon">💩</span><strong>Poop</strong><b>→</b><em>Shredder</em><small>S1: D, then S2: ←</small></div></div><p>Build phase: connect all receiver ports before Next Turn.</p>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Start Game', startGame, 'primary-button'));
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function showTurnResult(r) {
|
||||
ui.modalTitle.textContent = `Turn ${r.turn} Result`;
|
||||
const contractHtml = r.contract ? `
|
||||
<div class="result-section-title">Limited Contract</div>
|
||||
<div class="result-grid">
|
||||
<div><strong>Contract</strong><span>${r.contract.title}</span></div>
|
||||
<div><strong>Rule</strong><span>${r.contract.route}</span></div>
|
||||
<div><strong>Progress</strong><span>${r.contract.count} / ${r.contract.targetAmount}</span></div>
|
||||
<div><strong>Bonus</strong><span>${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}</span></div>
|
||||
</div>` : '<div class="result-section-title muted-title">No limited contract active this turn.</div>';
|
||||
ui.modalBody.innerHTML = `
|
||||
<div class="result-section-title">Shipment</div>
|
||||
<div class="result-grid">
|
||||
<div><strong>Truck target</strong><span>${r.ship.target.toUpperCase()}</span></div>
|
||||
<div><strong>Target cargo</strong><span>${r.ship.targetCount} × ${yen(TRUCK_PRICE)} = ${yen(r.ship.base)}</span></div>
|
||||
<div><strong>Poop in truck</strong><span>${r.poopTruck} poop → ${Math.round(r.ship.soilMultiplier * 100)}%</span></div>
|
||||
<div><strong>Final truck revenue</strong><span>${yen(r.ship.adjusted)}</span></div>
|
||||
<div><strong>Mixer</strong><span>${r.mixerCount} × ${yen(MIXER_PRICE)}</span></div>
|
||||
<div><strong>Wrong truck</strong><span>${r.truckMale} males</span></div>
|
||||
<div><strong>Explosion damage</strong><span>${yen(r.explosionDamage)}</span></div>
|
||||
<div><strong>Turn profit</strong><span>${yen(r.profit)}</span></div>
|
||||
<div><strong>Cash</strong><span>${yen(r.cash)}</span></div>
|
||||
</div>
|
||||
${contractHtml}`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Build Phase', hideModal, 'primary-button'));
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function showGameOver() {
|
||||
const processed = game.totals.processed;
|
||||
const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0;
|
||||
const fs = finalScore(game);
|
||||
ui.modalTitle.textContent = 'Game Over';
|
||||
ui.modalBody.innerHTML = `<p>Your cash went negative.</p><div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} turns</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Total Penalty</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} truck / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Damage</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
|
||||
ui.modalActions.innerHTML = '';
|
||||
ui.modalActions.appendChild(button('Restart', startGame, 'primary-button'));
|
||||
ui.modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function button(text, onClick, className = 'primary-button') {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = className;
|
||||
b.textContent = text;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function hideModal() { ui.modal.classList.remove('visible'); }
|
||||
|
||||
function checkGameOver() {
|
||||
if (game.phase !== 'gameover' && game.cash < 0) {
|
||||
game.phase = 'gameover';
|
||||
showGameOver();
|
||||
}
|
||||
}
|
||||
|
||||
return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver };
|
||||
}
|
||||
119
styles.css
Normal file
119
styles.css
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
: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: 10px; }
|
||||
.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: 10px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
|
||||
.panel-head p { color: var(--muted); margin-top: 4px; font-size: 10px; line-height: 1.25; }
|
||||
.panel-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: 14px; margin: 0 0 6px; }
|
||||
.facility-card p { margin: 4px 0; }
|
||||
.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.facility-action { padding: 7px 9px; font-size: 10px; }
|
||||
.facility-action.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: 10px; }
|
||||
.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue