Skip to content
54 changes: 53 additions & 1 deletion game/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
window.SOP = window.SOP || {};

SOP.InmateAI = class InmateAI {
/** Create an idle inmate at tile coordinates (x, y) with randomised initial needs. */
constructor(name, trait, x, y) {
this.name = name;
this.trait = trait;
Expand Down Expand Up @@ -36,124 +37,128 @@
}

/* ---------------- UTILITY EVALUATOR ---------------- */
/**
* Choose the highest-scoring drive for a living inmate. Reset progress and movement
* when the action changes or has no goal; otherwise retain the current goal.
*/
evaluateDrives(world) {
if (this.dead) return;
const A = this.affective;
const cand = [];
const push = (name, score, goal) => { if (score > 0) cand.push({ name, score, goal }); };

// broken infrastructure survey (drives both repair and crank decisions)
const brokenAll = world.devices.filter(d => d.broken && d.kind !== 'gas_vent');
const gridBroken = brokenAll.some(d => this.onDispenserGrid(world, d));

// --- EAT ---
let eatScore = A.hunger > 70 ? A.hunger * 1.5 : A.hunger;
const dispenser = this.nearestDevice(world, d => d.kind === 'dispenser' && !d.broken && d.powered);
const dispenserAny = dispenser || this.nearestDevice(world, d => d.kind === 'dispenser' && !d.broken);
if (world.res.rationen <= 0 || !dispenser) eatScore = 0;
push('ESSEN FASSEN', eatScore, dispenser ? { kind: 'device', dev: dispenser, act: 'eat' } : null);

// --- SLEEP ---
let sleepScore = A.exhaustion > 75 ? A.exhaustion * 1.3 : A.exhaustion * 0.7;
const bed = this.nearestDevice(world, d => d.kind === 'bed' && !d.broken &&
!world.inmates.some(i => i !== this && !i.dead && i.goal && i.goal.dev === d && i.sleeping));
if (bed) push('SCHLAFEN', sleepScore, { kind: 'device', dev: bed, act: 'sleep' });
else if (A.exhaustion > 60) push('SCHLAFEN (BODEN)', sleepScore * 0.8, { kind: 'tile', x: this.tx, y: this.ty, act: 'sleep' });

// --- CRANK DYNAMO (sticky: hold the cranks until exhausted) ---
const p = world.power;
if (this.goal && this.goal.act === 'crank' && this.goal.dev &&
!this.goal.dev.broken && this.goal.dev.operatedBy === this.id &&
A.exhaustion < 85) {
push('KURBELN', 95, this.goal);
} else if (A.exhaustion < 80 || A.hunger > 85) {
// hunger > 85: desperate cranking — starvation overrides exhaustion
const dyn = this.nearestDevice(world, d => d.kind === 'dynamo' && !d.broken && d.operatedBy == null && d.compDeficit > 0);
if (dyn) {
let crankScore = 62 + Math.min(30, dyn.compDeficit * 0.4) - A.exhaustion * 0.4;
// a hungry inmate powers the dispenser before trying to eat —
// but if the food grid is broken, repairing beats cranking
if (dispenserAny && dispenserAny.compDeficit > 0 && !gridBroken) {
if (A.hunger > 85) crankScore += 120; // starving override — beats sleep
else if (A.hunger > 55) crankScore += 40;
}
push('KURBELN', crankScore, { kind: 'device', dev: dyn, act: 'crank' });
}
}

// --- DIG (Standard Operating Procedure) ---
let workScore = 72 - A.stress * 0.35 - A.exhaustion * 0.6;
if (this.trait === 'Befehlsfixiert') workScore += 25;
// Blackboard work directive: low stocks order everyone underground
if (world.res.oel < 100 || world.res.rationen < 60 || world.res.metall < 10) workScore += 35;
const mark = this.nearestDigMark(world);
if (mark) {
// dig momentum: a tile already being excavated gets finished
if (this.goal && this.goal.act === 'dig' && this.progress > 0) workScore += 40;
push('GRABEN', workScore, { kind: 'dig', x: mark.x, y: mark.y, act: 'dig' });
}

// --- REPAIR ---
// prioritize broken nodes on the food/power grid over cosmetic fixes
let broken = null;
if (brokenAll.length) {
const crit = brokenAll.filter(d => this.onDispenserGrid(world, d));
const pool = crit.length ? crit : brokenAll;
broken = pool.reduce((a, b) =>
(Math.abs(a.x - this.tx) + Math.abs(a.y - this.ty)) <= (Math.abs(b.x - this.tx) + Math.abs(b.y - this.ty)) ? a : b);
}
if (broken) {
let repairScore = 60 - A.exhaustion * 0.3;
if (this.onDispenserGrid(world, broken)) repairScore = 210; // must beat desperate cranking
push('REPARIEREN', repairScore, { kind: 'device', dev: broken, act: 'repair' });
}

// --- FLEE DARKNESS (Paranoia) — capped so hunger can still win ---
const here = world.tiles[this.ty][this.tx];
if (!here.lit && here.t === 'empty' && !this.sleeping) {
let flee = A.paranoia * 1.2;
if (this.trait === 'Paranoid') flee *= 1.6;
if (this.trait === 'Befehlsfixiert') flee *= 0.3;
flee = Math.min(flee, 115);
const lit = world.nearestLit(this.tx, this.ty);
if (lit) push('LICHT SUCHEN', flee, { kind: 'tile', x: lit.x, y: lit.y, act: 'flee' });
}

// --- AVOID GAS ---
if (here.gas > 12) {
const clear = this.nearestClearAir(world);
if (clear) push('GAS MEIDEN', 92 + here.gas * 0.1, { kind: 'tile', x: clear.x, y: clear.y, act: 'flee' });
}

// --- SABOTAGE / PSYCHOTIC BREAK (trait-specific conative override) ---
if (A.stress >= 85 && this.trait === 'Pyromane') {
push(`PSYCHOTISCHER SCHUB (${this.trait})`, 100, this.pickSabotageTarget(world));
}
if (A.stress >= 92 && this.trait === 'Paranoid') {
const lit = world.nearestLit(this.tx, this.ty);
if (lit) push('IN ECKE VERKRIECHEN', 96, { kind: 'tile', x: lit.x, y: lit.y, act: 'hide' });
}

// --- WANDER baseline ---
push('UMHERIRREN', 25, this.randomWanderGoal(world));

let best = cand[0] || { name: 'IDLE', score: 0, goal: null };
for (const c of cand) if (c.score > best.score) best = c;

// commit
if (best.name !== this.currentAction.name || !this.goal) {
this.currentAction = { name: best.name, score: best.score };
this.goal = best.goal;
this.sleeping = false;
this.progress = 0;
this.path = [];
this.waitTicks = 0;
this.thought = this.thoughtFor(best.name);
}
this.logConativeState(best);
}

Check notice on line 159 in game/ai.js

View check run for this annotation

codefactor.io / CodeFactor

game/ai.js#L44-L159

Complex Method

/* Is this device on the same wired component as any dispenser? */
/** Return whether dev is connected to any dispenser, including the dispenser itself. */
onDispenserGrid(world, dev) {
const starts = world.devices.filter(d => d.kind === 'dispenser');
if (!starts.length) return false;
Expand All @@ -171,13 +176,15 @@
return seen.has(dev.id);
}

/** Return a random sabotage goal for a wired device other than a gas vent, or null. */
pickSabotageTarget(world) {
const linked = world.devices.filter(d => d.links.length > 0 && d.kind !== 'gas_vent');
if (!linked.length) return null;
const d = linked[Math.floor(Math.random() * linked.length)];
return { kind: 'device', dev: d, act: 'sabotage' };
}

/** Return a walkable wander goal, or null if none is found in 20 random attempts. */
randomWanderGoal(world) {
for (let tries = 0; tries < 20; tries++) {
const x = Math.floor(Math.random() * world.W);
Expand All @@ -187,6 +194,10 @@
return null;
}

/**
* Return the nearest device matching pred by Manhattan distance, or null.
* Reachability is not checked; errors thrown by pred propagate to the caller.
*/
nearestDevice(world, pred) {
let best = null, bd = Infinity;
for (const d of world.devices) {
Expand All @@ -197,6 +208,10 @@
return best;
}

/**
* Return the nearest dirt dig mark with a walkable neighbour, preferring unclaimed
* marks, or null if none qualifies. Distance is Manhattan; no path is checked.
*/
nearestDigMark(world) {
const taken = new Set();
for (const o of world.inmates) {
Expand All @@ -216,6 +231,10 @@
return best || bestTaken;
}

/**
* Return the nearest empty tile with gas at most 10 and oxygen at least 30
* on their 0–100 scales, or null. Use Manhattan distance without checking a path.
*/
nearestClearAir(world) {
let best = null, bd = Infinity;
for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) {
Expand All @@ -227,6 +246,7 @@
return best;
}

/** Return the display thought for an action name, falling back to an em dash. */
thoughtFor(action) {
const T = {
'ESSEN FASSEN': 'Ausgabe. Jetzt.',
Expand All @@ -245,70 +265,79 @@
return T[action] || '—';
}

/** Release all devices in world operated by this inmate. */
releaseCrank(world) {
for (const d of world.devices) if (d.operatedBy === this.id) d.operatedBy = null;
}

/* ---------------- PER-TICK EXECUTION ---------------- */
/**
* Advance needs, health, movement and actions by one simulation tick; ignore dead inmates.
* Deliver action events synchronously to onEvent; callback errors propagate.
*/
tick(world, onEvent) {
if (this.dead) return;
// dynamo ownership sanity: a cranker who switched goals frees the cranks
if (!this.goal || this.goal.act !== 'crank') this.releaseCrank(world);
const A = this.affective;
const tl = world.tiles[this.ty][this.tx];

// ---- affective drift ----
A.hunger = Math.min(100, A.hunger + this.hungerRate);
if (!this.sleeping) A.exhaustion = Math.min(100, A.exhaustion + 0.18);
if (!tl.lit && tl.t === 'empty' && !this.sleeping) {
A.stress = Math.min(100, A.stress + 0.15);
A.paranoia = Math.min(100, A.paranoia + (this.trait === 'Paranoid' ? 0.5 : 0.22));
} else {
A.paranoia = Math.max(0, A.paranoia - 0.35);
A.stress = Math.max(0, A.stress - 0.15);
}
// breathing
if (tl.o2 < 14 || tl.gas > 12) {
A.o2 = Math.min(100, A.o2 + (tl.gas > 12 ? 2.4 : 1.1));
} else {
A.o2 = Math.max(0, A.o2 - 1.4);
}
if (A.o2 >= 100) { this.hp -= 2.5; if (this.hp <= 0) return this.die('ERSTICKT'); }
if (A.hunger >= 100) { this.hp -= 0.8; if (this.hp <= 0) return this.die('AUSGEZEHRT'); }

// ---- sleep is its own loop (recovery happens HERE, not in execute) ----
if (this.sleeping) {
const bed = this.goal && this.goal.dev && this.goal.dev.kind === 'bed' ? this.goal.dev : null;
A.exhaustion = Math.max(0, A.exhaustion - (bed ? 1.5 : 0.9));
A.stress = Math.max(0, A.stress - 0.3);
if (A.exhaustion <= 3 || A.o2 > 60 || tl.gas > 12 || A.hunger >= 85) {
this.sleeping = false;
this.goal = null;
}
if (this.sayCooldown > 0) this.sayCooldown--;
return;
}

// ---- movement along path (one tile every other tick) ----
this.moveGate = !this.moveGate;
if (this.moveGate && this.path.length && !this.executingHere) {
const [nx, ny] = this.path[0];
if (world.walkable(nx, ny) || (this.path.length === 1 && this.isGoalTile(nx, ny))) {
this.tx = nx; this.ty = ny;
this.path.shift();
this.walkPhase += 1;
} else {
this.repath(world);
}
}

// ---- goal arrival / execution ----
if (this.goal) this.execute(world, onEvent);

this.executingHere = false;
if (this.sayCooldown > 0) this.sayCooldown--;
}

Check notice on line 335 in game/ai.js

View check run for this annotation

codefactor.io / CodeFactor

game/ai.js#L278-L335

Complex Method

/**
* Return whether tile (x, y) is close enough to execute the current goal.
* Flee and hide tile goals require an exact match; other goals allow one orthogonal step.
*/
isGoalTile(x, y) {
const g = this.goal;
if (!g) return false;
Expand All @@ -319,6 +348,10 @@
return false;
}

/**
* Replace the route to the current goal or an adjacent standing tile.
* Abandon the goal if no standing tile or path is available.
*/
repath(world) {
const g = this.goal;
if (!g) { this.path = []; return; }
Expand All @@ -339,112 +372,125 @@
if (!p) this.goal = null; // unreachable — re-evaluate next round
}

/**
* Return the first walkable neighbour in east, west, south, north order, or null.
* Inmate occupancy and reachability from the current position are not checked.
*/
adjacentStand(world, x, y) {
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
if (world.walkable(x + dx, y + dy)) return { x: x + dx, y: y + dy };
}
return null;
}

/**
* Advance the current action when within reach, otherwise request a route if needed.
* May change needs, resources, terrain or devices and clear the goal.
* Deliver action events synchronously to onEvent; callback errors propagate.
*/
execute(world, onEvent) {
const g = this.goal;
if (!g) return;

// en route?
if (g.kind === 'tile' && !this.isGoalTile(this.tx, this.ty)) {
if (!this.path.length) this.repath(world);
return;
}
if (g.kind === 'device' && !this.isGoalTile(this.tx, this.ty)) {
if (!this.path.length) this.repath(world);
return;
}
if (g.kind === 'dig' && !this.isGoalTile(this.tx, this.ty)) {
if (!this.path.length) this.repath(world);
return;
}

this.executingHere = true;
const A = this.affective;

switch (g.act) {
case 'eat': {
if (world.res.rationen <= 0 || g.dev.broken) { this.goal = null; return; }
if (!g.dev.powered) {
// stand at the Ausgabe and wait for the grid (someone is cranking…)
this.waitTicks = (this.waitTicks || 0) + 1;
if (this.waitTicks > 40) { this.waitTicks = 0; this.goal = null; }
return;
}
this.waitTicks = 0;
world.res.rationen--;
A.hunger = Math.max(0, A.hunger - 45);
A.stress = Math.max(0, A.stress - 4);
onEvent({ type: 'meal', inmate: this });
this.goal = null;
break;
}
case 'sleep': {
// the sleep loop itself lives in tick(); getting here just commits
this.sleeping = true;
break;
}
case 'crank': {
if (!g.dev || g.dev.broken) { this.goal = null; return; }
if (g.dev.operatedBy != null && g.dev.operatedBy !== this.id) { this.goal = null; return; }
g.dev.operatedBy = this.id;
A.exhaustion = Math.min(100, A.exhaustion + 0.35);
A.hunger = Math.min(100, A.hunger + 0.10);
A.stress = Math.min(100, A.stress + 0.05);
if (A.exhaustion > 85) { g.dev.operatedBy = null; this.goal = null; }
break;
}
case 'dig': {
if (world.tiles[g.y][g.x].t !== 'dirt') { world.Blackboard.removeDigMark(g.x, g.y); this.goal = null; return; }
this.progress++;
A.stress = Math.min(100, A.stress + 0.10);
A.exhaustion = Math.min(100, A.exhaustion + 0.15);
if (this.progress >= SOP.DIG_TICKS) {
const ev = world.completeDig(g.x, g.y, this);
if (ev && ev.type === 'gas') onEvent({ type: 'gasburst', x: g.x, y: g.y });
if (ev && ev.type === 'cache') onEvent({ type: 'cache', inmate: this, label: ev.label, n: ev.n, res: ev.res });
this.progress = 0;
this.goal = null;
}
break;
}
case 'repair': {
if (!g.dev || !g.dev.broken) { this.goal = null; return; }
this.progress++;
if (this.progress >= 6) { g.dev.broken = false; this.progress = 0; onEvent({ type: 'repair', dev: g.dev, inmate: this }); this.goal = null; }
break;
}
case 'sabotage': {
if (!g.dev || g.dev.links.length === 0) { this.goal = null; A.stress = Math.max(0, A.stress - 25); return; }
const other = world.devices.find(d => d.id === g.dev.links[0]);
if (other) {
world.removeLink(g.dev, other);
onEvent({ type: 'sabotage', inmate: this, dev: g.dev });
}
A.stress = Math.max(0, A.stress - 30);
this.goal = null;
break;
}
case 'flee':
case 'hide':
case 'wander': {
this.goal = null; // arrived; next evaluation picks something new
break;
}
case 'expedition': {
// reached the airlock — hand control back to the Schichtleitung
onEvent({ type: 'board', inmate: this, dev: g.dev });
this.goal = null;
break;
}
}
}

Check notice on line 488 in game/ai.js

View check run for this annotation

codefactor.io / CodeFactor

game/ai.js#L391-L488

Complex Method

/**
* Mark this inmate dead with the display cause, end sleep and release operated devices
* in SOP.World.
*/
die(cause) {
this.dead = true;
this.deadCause = cause;
Expand All @@ -454,6 +500,10 @@
}

/* ---------------- SAVE / LOAD ---------------- */
/**
* Return save data with rounded health and copied needs and expedition state.
* Omit the current goal, route, sleep state and action progress.
*/
serialize() {
return {
id: this.id, name: this.name, trait: this.trait,
Expand All @@ -465,6 +515,7 @@
};
}

/** Restore an inmate from save data with idle action state; reuse d.away when present. */
static fromData(d) {
const i = new SOP.InmateAI(d.name, d.trait, d.tx, d.ty);
i.id = d.id || d.name;
Expand All @@ -477,6 +528,7 @@
return i;
}

/** Retain the scored action by reference as lastScored. */
logConativeState(action) {
this.lastScored = action;
}
Expand Down
6 changes: 5 additions & 1 deletion game/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ SOP.Audio = (() => {
}
}

/** Start or stop the sequencer; return whether playback is running. */
/**
* Start or stop scheduling notes; return whether the sequencer is running.
* Return false if Web Audio is unavailable; audio initialisation errors propagate.
* Stopping leaves already scheduled notes to finish.
*/
function toggle() {
init();
if (!ctx) return false;
Expand Down
16 changes: 13 additions & 3 deletions game/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,53 +66,53 @@

/* ---------------- SIMULATION TICK ---------------- */
/** Advance an active, unpaused shift by one tick, processing events and autosave. */
function simTick() {
if (!started || paused || gameOver) return;
ticks++;

world.tick(handleWorldEvent);

// expedition returns
for (const inm of world.inmates) {
if (!inm.dead && inm.away && ticks >= inm.away.returnTick) returnFromExpedition(inm);
}

for (const inm of world.inmates) {
if (inm.dead || inm.away) continue;
// boarding failsafe: path impossible → abandon the expedition
if (inm.boarding && !inm.goal) {
inm.boarding = false;
UI.log('EXPEDITION ABGEBROCHEN: SCHLEUSE NICHT ERREICHBAR.', 'alert');
continue;
}
if (ticks % 4 === 0 && !inm.boarding) inm.evaluateDrives(world); // 4 ticks = 1 s
inm.tick(world, handleInmateEvent);
}

// newcomer at the airlock
if (ticks >= nextArrivalTick) {
const arrived = tryArrival();
if (arrived) {
scheduleNextArrival();
} else {
// stand blocked or colony full — peek again soon
const full = world.inmates.filter(i => !i.dead).length >= SOP.MAX_INMATES;
nextArrivalTick = ticks + (full ? 2400 : 120 + Math.floor(Math.random() * 180));
}
}

// random seismic event
if (ticks >= nextQuakeTick) {
quake();
nextQuakeTick = ticks + 380 + Math.floor(Math.random() * 260);
}

// autosave every ~5 minutes of sim time
if (ticks % 1200 === 0 && !gameOver) saveGame(true);

// game over check
if (world.inmates.every(i => i.dead)) endGame();
}

Check notice on line 115 in game/main.js

View check run for this annotation

codefactor.io / CodeFactor

game/main.js#L69-L115

Complex Method

/** Log generator fuel consumption and farm harvest events from the world. */
function handleWorldEvent(ev) {
Expand Down Expand Up @@ -275,7 +275,10 @@
};
}

/** Save the current snapshot to localStorage and return success; silent suppresses logs. */
/**
* Save the current snapshot to localStorage and return success; silent suppresses logs.
* Return false when storage is unavailable or serialisation or writing fails.
*/
function saveGame(silent) {
const st = storage();
if (!st) { if (!silent) UI.log('STORAGE BLOCKIERT. NUTZE EXPORT.', 'alert'); return false; }
Expand All @@ -289,7 +292,10 @@
}
}

/** Restore a version-1 snapshot and resume play; return false on format or load errors. */
/**
* Restore a version-1 snapshot and resume play; return true on success or false on format
* or load errors. Failed loads do not roll back changes already applied to the game.
*/
function loadGame(data) {
try {
if (!data || data.v !== 1) { UI.log('AKTE UNLESBAR (FALSCHES FORMAT).', 'alert'); return false; }
Expand Down Expand Up @@ -343,7 +349,11 @@
}
}

/** Read a File as text, parse its JSON, and attempt to restore the saved game. */
/**
* Read a File as text asynchronously and attempt to restore its JSON snapshot.
* JSON parse errors are caught and load errors become loadGame's false result.
* Synchronous FileReader errors propagate; asynchronous read errors have no handler.
*/
function importSave(file) {
const r = new FileReader();
r.onload = () => {
Expand Down Expand Up @@ -433,50 +443,50 @@
}
});

canvas.addEventListener('mousedown', e => {
if (!started || gameOver) return;
const r = canvas.getBoundingClientRect();
const t = R.toTile(e.clientX - r.left, e.clientY - r.top);
const { x, y } = t;
if (!world.inGrid(x, y)) return;

if (e.button === 2) { // right click cancels
state.buildKind = null; state.linkFrom = null;
UI.clearBuildSelection();
return;
}

if (R.getView() === 'CROSSLINK') { crosslinkClick(x, y); return; }

// ---- PHYSICAL MODE ----
if (state.buildKind === 'dig') {
if (world.orderDig(x, y)) UI.log(`GRABUNGSAUFTRAG ${x}/${y} ERTEILT.`, 'dim');
return;
}
if (state.buildKind === 'wall') {
const r2 = world.tryBuildWall(x, y);
if (!r2.ok) UI.log('BAU ABGELEHNT: ' + r2.err, 'alert');
else UI.log('WAND GEZOGEN.', 'dim');
return;
}
if (state.buildKind) {
const r2 = world.tryBuild(state.buildKind, x, y);
if (!r2.ok) UI.log('BAU ABGELEHNT: ' + r2.err, 'alert');
else {
UI.log(`${SOP.OBJ[state.buildKind].label} INSTALLIERT. VERKABELUNG IM CROSSLINK.`, 'ok');
state.buildKind = null;
UI.clearBuildSelection();
}
return;
}

// select inmate or device
const inm = world.inmates.find(i => !i.dead && !i.away && i.tx === x && i.ty === y);
if (inm) { state.selected = inm; state.selDevice = null; return; }
const tl = world.tiles[y][x];
if (tl.obj) { state.selDevice = tl.obj; state.selected = null; }
else { state.selected = null; state.selDevice = null; }
});

Check notice on line 489 in game/main.js

View check run for this annotation

codefactor.io / CodeFactor

game/main.js#L446-L489

Complex Method

/** Select a device at tile (x, y), or toggle its wire to the previously selected node. */
function crosslinkClick(x, y) {
Expand Down
29 changes: 29 additions & 0 deletions game/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
let logLines = [];

/* ---------------- EVENT LOG ---------------- */
/**
* Prepend a day-stamped message to the display, retaining at most 70 entries.
* msg is inserted as HTML; cls selects the l-* CSS class and defaults to ok.
*/
function log(msg, cls) {
const day = SOP.Main ? SOP.Main.day() : 0;
logLines.push({ msg, cls: cls || 'ok', day });
Expand All @@ -35,14 +39,20 @@
).reverse().join('');
}

/** Display an alert message and sound the alarm when audio is on. */
function alert(msg) {
log(msg, 'alert');
if (SOP.Audio.on) SOP.Audio.alarm();
}

/** Display a message with the wiring event style. */
function wire(msg) { log(msg, 'wire'); }

/* ---------------- TICKER ---------------- */
/**
* Replace ticker text with the standard lines, optionally prefixed by extra.
* An extra announcement highlights the ticker and schedules a reset after six seconds.
*/
function setTicker(extra) {
const lines = extra ? [extra, ...SOP.TICKER_LINES] : SOP.TICKER_LINES;
el.ticker.textContent = lines.join(' /// ') + ' /// ';
Expand All @@ -53,6 +63,7 @@
}

/* ---------------- BUILD MENU ---------------- */
/** Populate build controls and hints; call onSelect(kind) when an item is clicked. */
function initBuildMenu(onSelect) {
let html = '';
for (const kind of SOP.BUILDABLE) {
Expand Down Expand Up @@ -80,17 +91,23 @@
el.buildHint.textContent = 'MODUL WÄHLEN, DANN IM HOHLRAUM PLATZIEREN. ESC BRICHT AB.';
}

/** Return a German cost label from resource quantities, or GRATIS for an empty cost. */
function costText(cost) {
const parts = [];
for (const k in cost) parts.push(cost[k] + ' ' + k.toUpperCase());
return parts.length ? 'KOSTET ' + parts.join(', ') : 'GRATIS';
}

/** Remove selection styling from build buttons. */
function clearBuildSelection() {
el.buildMenu.querySelectorAll('.build-item').forEach(x => x.classList.remove('selected'));
}

/* ---------------- SYSTEM PANEL ---------------- */
/**
* Refresh the HUD with the day, power, resources, living inmate count and atmosphere
* averaged over empty tiles.
*/
function updateSystem(world, day) {
el.sysDay.textContent = day;
const p = world.power;
Expand Down Expand Up @@ -119,6 +136,10 @@
/* ---------------- SUBJECT INSPECTOR ---------------- */
const NEED_LABEL = { stress: 'STRESS', hunger: 'HUNGER', exhaustion: 'ERSCHÖPFUNG', paranoia: 'ARGWOHN', o2: 'O₂-MANGEL' };

/**
* Show the selected inmate’s needs and action, or an empty, dead or away state.
* world is unused.
*/
function updateInspector(world, selected) {
if (!selected || selected.dead) {
el.inspector.innerHTML = `<div class="panel-title">SUBJEKT-INSPEKTOR</div>
Expand Down Expand Up @@ -163,82 +184,89 @@
/* ---------------- DEVICE INSPECTOR (crosslink / airlock) ---------------- */
let devSig = '';

/**
* Refresh device details and controls when tracked display state changes.
* mode selects the empty-panel hint; ticksNow is elapsed simulation ticks
* used for expedition estimates and defaults to zero when omitted.
*/
function updateDevicePanel(world, dev, mode, ticksNow) {
const awaySig = world.inmates.filter(i => !i.dead && i.away)
.map(i => i.name + ':' + i.away.returnTick).join(',')
+ '#' + world.inmates.filter(i => !i.dead && !i.away && !i.boarding).map(i => i.name).join(',');
const sig = dev
? [dev.id, dev.kind, dev.powered, dev.broken, dev.enabled, dev.links.length,
dev.kind === 'battery' ? Math.floor((dev.charge || 0) / 50) : 0,
dev.kind === 'generator' ? (dev.fueled === false ? 1 : 0) : 0,
mode, awaySig,
dev.kind === 'airlock' ? Math.floor((ticksNow || 0) / 40) : 0].join('|')
: 'none|' + mode;
if (sig === devSig) return;
devSig = sig;

if (!dev) {
el.devInspector.innerHTML = `<div class="panel-title">KNOTEN-INSPEKTOR</div>
<div class="empty">${mode === 'CROSSLINK' ? 'Knoten anklicken. Zwei Knoten = Leitung.' : 'Modul anklicken, um Details zu sehen.'}</div>`;
return;
}
const def = SOP.OBJ[dev.kind];
let html = `
<div class="panel-title">${dev.kind === 'airlock' ? 'SCHLEUSEN-STEUERUNG' : 'KNOTEN-INSPEKTOR'}</div>
<div class="kv"><span class="k">TYP</span><span>${def.label}</span></div>
<div class="kv"><span class="k">LAST</span><span>${def.watts ? (def.watts < 0 ? '−' + Math.abs(def.watts) + ' W (ERZEUGER)' : '+' + def.watts + ' W') : 'PASSIV'}</span></div>
<div class="kv"><span class="k">STATUS</span><span style="color:${dev.broken ? 'var(--alert)' : dev.powered ? 'var(--ok)' : 'var(--text-dim)'}">${dev.broken ? 'DEFEKT' : dev.powered ? 'AKTIV' : 'OHNE STROM'}</span></div>
<div class="kv"><span class="k">SCHALTER</span><span>${dev.enabled === false ? 'AUS' : 'EIN'}</span></div>
${dev.kind === 'battery' ? `<div class="kv"><span class="k">LADUNG</span><span>${Math.round(dev.charge)} / ${SOP.BATTERY_CAP}</span></div>` : ''}
${dev.kind === 'generator' ? `<div class="kv"><span class="k">TANK</span><span>${dev.fueled === false ? 'LEER' : 'LÄUFT'}</span></div>` : ''}
${dev.kind !== 'airlock' ? `<div class="kv"><span class="k">LEITUNGEN</span><span>${dev.links.length}</span></div>` : ''}`;

if (dev.kind === 'airlock') {
const away = world.inmates.filter(i => !i.dead && i.away);
const candidates = world.inmates.filter(i => !i.dead && !i.away && !i.boarding);
html += `<div style="border-top:1px dashed var(--border-color); margin-top:8px; padding-top:8px">
<div style="color:var(--text-dim)">// EXPEDITION ZUR OBERFLÄCHE</div>`;
if (away.length) {
for (const i of away) {
const eta = Math.max(0, Math.round((i.away.returnTick - (ticksNow || 0)) * SOP.TICK_MS / 1000));
html += `<div class="kv"><span class="k">${i.name}</span><span class="blink">AUSSERHAUS · ~${eta} S</span></div>`;
}
}
if (candidates.length) {
html += `<select id="exp-subject" style="width:100%; background:#000; color:var(--text-main);
border:1px solid var(--text-main); border-radius:0; font-family:inherit; font-size:11px;
padding:4px; margin:6px 0">
${candidates.map(i => `<option value="${i.name}">${i.name} (${i.trait})</option>`).join('')}
</select>
<button class="btn" id="btn-expedition" style="width:100%">EXPEDITION STARTEN</button>`;
} else if (!away.length) {
html += `<div class="empty">Keine Subjekte verfügbar.</div>`;
}
html += `</div>
<div style="color: var(--text-dim); margin-top:6px; font-size:11px">BEUTE: ÖL, KONSERVEN, SCHROTT. RISIKO: VERLETZUNG.</div>`;
} else {
html += `<button class="btn" id="btn-toggle-dev" style="margin-top:8px; width:100%">
${dev.enabled === false ? 'EINSCHALTEN [E]' : 'ABSCHALTEN [E]'}</button>
<div style="color: var(--text-dim); margin-top:6px; font-size:11px">ZWEITEN KNOTEN ANKLICKEN → LEITUNG LEGEN / TRENNEN (MAX ${SOP.LINK_RANGE} FELDER).</div>`;
}

el.devInspector.innerHTML = html;
const tb = document.getElementById('btn-toggle-dev');
if (tb) tb.addEventListener('click', () => SOP.UI.toggleDevice(dev));
const eb = document.getElementById('btn-expedition');
if (eb) eb.addEventListener('click', () => {
const sel = document.getElementById('exp-subject');
if (sel && sel.value && SOP.Main) SOP.Main.startExpedition(sel.value);
});
}

Check notice on line 259 in game/ui.js

View check run for this annotation

codefactor.io / CodeFactor

game/ui.js#L192-L259

Complex Method

/** Toggle a device’s enabled state, releasing its operator when disabling a dynamo. */
function toggleDevice(dev) {
dev.enabled = dev.enabled === false ? true : false;
if (dev.kind === 'dynamo' && dev.enabled === false) dev.operatedBy = null;
log(`${SOP.OBJ[dev.kind].label} ${dev.enabled === false ? 'ABGESCHALTET' : 'EINGESCHALTET'}.`, 'dim');
}

/* ---------------- OVERLAYS ---------------- */
/** Show the intro and bind its start button to hide it and call onStart. */
function showIntro(onStart) {
el.overlayIntro.classList.add('show');
$('btn-start').addEventListener('click', () => {
Expand All @@ -249,6 +277,7 @@
});
}

/** Show stats as HTML in the game-over overlay; bind restart to hide it and call onRestart. */
function showGameOver(stats, onRestart) {
el.overStats.innerHTML = stats;
el.overlayOver.classList.add('show');
Expand Down
Loading
Loading