From 514469d93c86fad87634c691c86147ef1a12adfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:58:08 +0000 Subject: [PATCH] Workers P2: Engine-Requisiten und Branchen-Icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Möbel und Requisiten aus der Vorlage (Schreibtisch, Monitor mit Schalter an/aus, Aktenschrank, Pflanze, Münze, Pokal, Stempel, Radar, Briefkasten, Tresor, Waage), neu Ablagekorb, Schild und die acht 7x7-Branchen-Icons mit drawIcon. Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/engine/props.js | 255 +++++++++++++++++++++++++ tests/unit/workersProps.test.mjs | 82 ++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/components/workers/engine/props.js create mode 100644 tests/unit/workersProps.test.mjs diff --git a/src/components/workers/engine/props.js b/src/components/workers/engine/props.js new file mode 100644 index 0000000..82a4a91 --- /dev/null +++ b/src/components/workers/engine/props.js @@ -0,0 +1,255 @@ +// Möbel, Requisiten und Branchen-Icons des Hustler-Hochhauses. +// Portiert aus entwuerfe/sprites.js (Möbel und Requisiten, ohne drawConveyor und drawZone). Neu: drawTray, drawSign, +// drawIcon samt ICONS und der Schalter `on` an drawMonitor. +// Alle Maße in logischen Einheiten; gezeichnet wird über px() aus sprites.js, es gibt keinen eigenen Zustand. +// Kein React, kein Appwrite, kein window/document. + +import { px, label, PAL, TEAM, HAT } from './sprites.js' + +// ---------- Möbel ---------- + +export function drawDesk(ctx, x, y, w, accent) { + px(ctx, x, y, w, 1, PAL.wood2) + px(ctx, x, y + 1, w, 4, PAL.wood) + px(ctx, x, y + 5, w, 1, PAL.wood2) + if (accent) px(ctx, x, y + 5, w, 1, accent) + px(ctx, x + 1, y + 6, 1, 5, PAL.wood2) + px(ctx, x + w - 2, y + 6, 1, 5, PAL.wood2) + px(ctx, x + 1, y + 11, w - 2, 1, PAL.shadow) +} + +// on = false: nur Rahmen, Schirm und Fuß, keine Zeilen und kein Blinken +export function drawMonitor(ctx, x, y, t, color, on = true) { + const c = color || '#7fb2ff' + px(ctx, x, y, 10, 7, PAL.o) + px(ctx, x + 1, y + 1, 8, 5, PAL.screen) + if (on) { + const long = Math.floor((t || 0) * 2) % 2 === 0 + px(ctx, x + 2, y + 2, 5, 1, c) + px(ctx, x + 2, y + 4, long ? 4 : 2, 1, c) + } + px(ctx, x + 4, y + 7, 2, 2, PAL.o) + px(ctx, x + 2, y + 9, 6, 1, PAL.o) +} + +export function drawCabinet(ctx, x, y, drawers) { + const n = drawers || 3 + px(ctx, x, y, 12, n * 6 + 1, PAL.metal2) + for (let i = 0; i < n; i++) { + px(ctx, x + 1, y + 1 + i * 6, 10, 5, PAL.metal) + px(ctx, x + 4, y + 3 + i * 6, 4, 1, PAL.metal2) + } + px(ctx, x, y + n * 6 + 1, 12, 1, PAL.shadow) +} + +export function drawPlant(ctx, x, y) { + px(ctx, x + 2, y, 2, 2, '#5cbf8a') + px(ctx, x, y + 2, 2, 2, '#5cbf8a') + px(ctx, x + 4, y + 1, 2, 3, '#3a9466') + px(ctx, x + 2, y + 2, 2, 3, '#3a9466') + px(ctx, x + 1, y + 5, 4, 4, '#c9714f') + px(ctx, x + 1, y + 5, 4, 1, '#a85a3c') +} + +// Ablagekorb / Eingangskorb, 10 × 4: voll = Papierstapel ragt über den Rand +export function drawTray(ctx, x, y, filled) { + px(ctx, x, y + 1, 10, 1, PAL.metal2) // Rand + px(ctx, x, y + 2, 10, 1, PAL.metal) // Vorderwand + px(ctx, x, y + 3, 10, 1, PAL.metal2) // Boden + if (filled) { + px(ctx, x + 1, y, 8, 1, PAL.g) // Oberkante des Stapels + px(ctx, x + 1, y + 1, 8, 1, PAL.w) // oberstes Blatt in der Öffnung + px(ctx, x + 3, y + 1, 4, 1, PAL.p) // eine Textzeile darauf + } +} + +// ---------- Requisiten ---------- + +export function drawCoin(ctx, x, y, t) { + const f = Math.floor((t || 0) * 6) % 4 + const w = [5, 3, 1, 3][f] + const ox = (5 - w) / 2 + px(ctx, x + ox, y, w, 5, PAL.gold2) + if (w > 2) px(ctx, x + ox + 1, y + 1, w - 2, 3, PAL.gold) +} + +export function drawTrophy(ctx, x, y) { + px(ctx, x, y, 7, 1, PAL.gold2) + px(ctx, x, y + 1, 7, 3, PAL.gold) + px(ctx, x + 1, y + 4, 5, 1, PAL.gold2) + px(ctx, x + 3, y + 5, 1, 2, PAL.gold2) + px(ctx, x + 1, y + 7, 5, 2, PAL.wood2) + px(ctx, x + 1, y + 1, 1, 2, '#fff3c4') +} + +export function drawStamp(ctx, x, y, ok) { + const c = ok ? PAL.green : PAL.red + px(ctx, x, y, 7, 1, c) + px(ctx, x, y + 4, 7, 1, c) + px(ctx, x, y, 1, 5, c) + px(ctx, x + 6, y, 1, 5, c) + if (ok) { + // Haken + px(ctx, x + 2, y + 2, 1, 1, c) + px(ctx, x + 3, y + 3, 1, 1, c) + px(ctx, x + 4, y + 1, 1, 2, c) + } else { + // Kreuz + px(ctx, x + 2, y + 1, 1, 1, c) + px(ctx, x + 4, y + 1, 1, 1, c) + px(ctx, x + 3, y + 2, 1, 1, c) + px(ctx, x + 2, y + 3, 1, 1, c) + px(ctx, x + 4, y + 3, 1, 1, c) + } +} + +export function drawRadar(ctx, x, y, t) { + px(ctx, x + 2, y, 10, 1, PAL.o) + px(ctx, x, y + 1, 14, 10, PAL.o) + px(ctx, x + 1, y + 2, 12, 8, '#e8f2fc') + px(ctx, x + 6, y + 5, 2, 2, TEAM.radar) + const a = (t || 0) * 2.2 + for (let i = 1; i < 6; i++) px(ctx, x + 7 + Math.round(Math.cos(a) * i), y + 6 + Math.round(Math.sin(a) * i * 0.7), 1, 1, TEAM.radar) + const blink = Math.floor((t || 0) * 3) % 3 + if (blink === 0) px(ctx, x + 3, y + 3, 1, 1, PAL.red) + if (blink === 1) px(ctx, x + 10, y + 7, 1, 1, PAL.red) + px(ctx, x + 6, y + 11, 2, 3, PAL.o) + px(ctx, x + 3, y + 14, 8, 1, PAL.o) +} + +// Briefkasten des Pults; das rote Abzeichen mit „!“ steht nur bei count > 0 +export function drawMailbox(ctx, x, y, count) { + px(ctx, x, y + 2, 12, 8, TEAM.freigabe) + px(ctx, x + 1, y, 10, 2, TEAM.freigabe) + px(ctx, x + 2, y + 4, 8, 2, HAT.overseer[1]) + px(ctx, x + 5, y + 10, 2, 5, PAL.o) + if (count > 0) { + px(ctx, x + 9, y - 3, 5, 5, PAL.red) + px(ctx, x + 11, y - 2, 1, 3, PAL.w) + } +} + +export function drawVault(ctx, x, y, t) { + px(ctx, x, y, 22, 20, PAL.metal2) + px(ctx, x + 1, y + 1, 20, 18, PAL.metal) + px(ctx, x + 7, y + 6, 8, 8, PAL.metal2) + px(ctx, x + 8, y + 7, 6, 6, PAL.metal) + const a = (t || 0) * 0.8 + px(ctx, x + 11 + Math.round(Math.cos(a) * 2), y + 10 + Math.round(Math.sin(a) * 2), 1, 1, PAL.o) + px(ctx, x + 18, y + 9, 2, 3, PAL.metal2) + px(ctx, x, y + 20, 22, 1, PAL.shadow) +} + +export function drawScale(ctx, x, y, tilt) { + const d = Math.round(tilt || 0) + px(ctx, x + 7, y + 2, 2, 12, PAL.o) + px(ctx, x + 3, y + 14, 10, 2, PAL.o) + px(ctx, x, y + 2 - d, 16, 1, PAL.o) + px(ctx, x - 1, y + 3 - d, 5, 2, TEAM.bewertung) + px(ctx, x + 12, y + 3 + d, 5, 2, TEAM.bewertung) +} + +// Weißes Schild mit dunklem Rand, 8 hoch; (x, y) = linke obere Ecke, Text zentriert (z. B. „noch nicht angeschlossen“) +export function drawSign(ctx, x, y, w, text) { + px(ctx, x, y, w, 8, PAL.o) + px(ctx, x + 1, y + 1, w - 2, 6, PAL.w) + label(ctx, text, x + w / 2, y + 2.5, { size: 9, color: PAL.ink, align: 'center' }) +} + +// ---------- Branchen-Icons (7 × 7) ---------- + +// '.' leer, 'x' Linie in der Teamfarbe, 'a' Akzent in Gold. Schlüssel = die acht Such-Räume (SEARCH_ROOMS) +const icon = (...rows) => Object.freeze(rows) +export const ICONS = Object.freeze({ + netz: icon( // Funkwellen + '.xxxxx.', + 'x.....x', + '..xxx..', + '.x...x.', + '.......', + '...a...', + '...a...', + ), + kleinanzeigen: icon( // Umschlag mit @-Punkt + 'xxxxxxx', + 'xx...xx', + 'x.x.x.x', + 'x..x..x', + 'x.....x', + 'x..a..x', + 'xxxxxxx', + ), + portale: icon( // Zeilenliste + 'xxxxxxx', + '.......', + 'a.xxxx.', + '.......', + 'a.xxxx.', + '.......', + 'a.xxxx.', + ), + foren: icon( // Sprechblase + '.xxxxx.', + 'x.....x', + 'x.a.a.x', + 'x.....x', + '.xxxxx.', + '..xx...', + '.x.....', + ), + aerzte: icon( // Kreuz + '..xxx..', + '..xxx..', + 'xxxxxxx', + 'xxxxxxx', + 'xxxxxxx', + '..xxx..', + '..xxx..', + ), + handwerk: icon( // Hammer + '.xxxxx.', + 'xxxxxxx', + '.xxxxx.', + '...a...', + '...a...', + '...a...', + '...a...', + ), + selbst: icon( // Person + '..xxx..', + '.xxxxx.', + '..xxx..', + '.......', + '.xxxxx.', + 'xxxxxxx', + 'xxxxxxx', + ), + laden: icon( // Markise + '.xxxxx.', + 'xaxaxax', + 'xaxaxax', + 'x.x.x.x', + '.......', + 'x.....x', + 'xxxxxxx', + ), +}) + +// Zeichnet das Icon `name` mit der linken oberen Ecke bei (x, y): 'x' in `color`, 'a' in Gold. +// Gleiche Zeichen einer Zeile werden zu einem Rechteck zusammengefasst; ein unbekannter Name zeichnet nichts. +export function drawIcon(ctx, name, x, y, color) { + const rows = typeof name === 'string' ? ICONS[name] : null + if (!Array.isArray(rows)) return + for (let r = 0; r < rows.length; r++) { + const row = rows[r] + let c = 0 + while (c < row.length) { + const ch = row[c] + let end = c + 1 + while (end < row.length && row[end] === ch) end++ + if (ch === 'x') px(ctx, x + c, y + r, end - c, 1, color) + else if (ch === 'a') px(ctx, x + c, y + r, end - c, 1, PAL.gold) + c = end + } + } +} diff --git a/tests/unit/workersProps.test.mjs b/tests/unit/workersProps.test.mjs new file mode 100644 index 0000000..8a79e96 --- /dev/null +++ b/tests/unit/workersProps.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +import { makeCtx } from './helpers/fakeCtx.mjs' + +// Engine-Requisiten: Möbel, Briefkasten, Tresor, Waage, Schild und die 7×7-Branchen-Icons +const P = await loadModule('src/components/workers/engine/props.js') +const { PAL } = await loadModule('src/components/workers/engine/sprites.js') +const { SEARCH_ROOMS } = await loadModule('src/lib/workersRoster.js') + +test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => { + const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0), + coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false), + radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) } + for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) } +}) +test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => { + const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length } + assert.equal(red(0), 0); assert.ok(red(3) >= 1) + const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length } + assert.ok(lines(true) >= 2); assert.equal(lines(false), 0) +}) +test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => { + assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst']) + for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) } + const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2) + const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5) +}) + +// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ---- + +test('ICONS: Schlüssel gleich SEARCH_ROOMS, eingefroren, jedes Icon hat Linien und die Zeichen stehen nur in .xa', () => { + assert.deepEqual([...SEARCH_ROOMS].sort(), Object.keys(P.ICONS).sort()) + assert.ok(Object.isFrozen(P.ICONS)) + for (const [k, rows] of Object.entries(P.ICONS)) { + assert.ok(Object.isFrozen(rows), k) + assert.ok(rows.join('').includes('x'), k) + } +}) +test('drawIcon: x in der Farbe, a in Gold, nichts außerhalb von 7 × 7; unbekannter Name zeichnet nichts und wirft nicht', () => { + const { ctx, rects } = makeCtx(); P.drawIcon(ctx, 'handwerk', 10, 20, '#3fb6b2') + assert.ok(rects.some((r) => r.fill === '#3fb6b2')); assert.ok(rects.some((r) => r.fill === PAL.gold)) + assert.ok(rects.every((r) => r.fill === '#3fb6b2' || r.fill === PAL.gold)) + assert.ok(rects.every((r) => r.x >= 30 && r.x + r.w <= 30 + 21 && r.y >= 60 && r.y + r.h <= 60 + 21)) + for (const name of ['gibt-es-nicht', 'constructor', '__proto__', 'toString', undefined, null]) { + const e = makeCtx(); assert.doesNotThrow(() => P.drawIcon(e.ctx, name, 0, 0, '#000'), String(name)); assert.equal(e.rects.length, 0, String(name)) + } +}) +test('drawIcon: jedes der acht Icons zeichnet sichtbar (mindestens 5 Rechtecke)', () => { + for (const name of Object.keys(P.ICONS)) { const { ctx, rects } = makeCtx(); P.drawIcon(ctx, name, 0, 0, '#123456'); assert.ok(rects.length >= 5, name) } +}) +test('drawMonitor: Standard ist an; aus hat kein Blinken, an blinkt mit t', () => { + const draw = (t, on) => { const { ctx, rects } = makeCtx(); if (on === undefined) P.drawMonitor(ctx, 0, 0, t, '#abcdef'); else P.drawMonitor(ctx, 0, 0, t, '#abcdef', on); return JSON.stringify(rects) } + assert.equal(draw(0.2), draw(0.2, true)) + assert.notEqual(draw(0.2, true), draw(0.7, true)) + assert.equal(draw(0.2, false), draw(0.7, false)) + const off = makeCtx(); P.drawMonitor(off.ctx, 0, 0, 1, '#abcdef', false); assert.ok(off.rects.length >= 2) // Rahmen und Schirm bleiben +}) +test('drawTray: voll zeigt Papier über dem Rand, beide bleiben in 10 × 4', () => { + const draw = (filled) => { const { ctx, rects } = makeCtx(); P.drawTray(ctx, 5, 7, filled); return rects } + const full = draw(true); const empty = draw(false) + assert.ok(full.some((r) => r.fill === PAL.w)); assert.ok(!empty.some((r) => r.fill === PAL.w)) + for (const rs of [full, empty]) assert.ok(rs.every((r) => r.x >= 15 && r.x + r.w <= 15 + 30 && r.y >= 21 && r.y + r.h <= 21 + 12)) + assert.equal(Math.max(...full.map((r) => r.x + r.w)) - Math.min(...full.map((r) => r.x)), 30) +}) +test('drawSign: dunkler Rand, weiße Fläche, Text mittig im Schild', () => { + const { ctx, rects, texts } = makeCtx(); P.drawSign(ctx, 4, 6, 40, 'Ablage') + assert.equal(rects[0].fill, PAL.o); assert.ok(rects.some((r) => r.fill === PAL.w)) + const border = rects[0]; assert.deepEqual([border.x, border.y, border.w], [12, 18, 120]) + assert.ok(rects.slice(1).every((r) => r.x > border.x && r.x + r.w < border.x + border.w && r.y > border.y && r.y + r.h < border.y + border.h)) + assert.equal(texts.length, 1); assert.equal(texts[0].x, (4 + 20) * 3) + assert.ok(texts[0].y > border.y && texts[0].y < border.y + border.h) +}) +test('drawStamp: ok grün, nein rot; drawCoin dreht sich mit t; drawScale kippt mit tilt', () => { + const fills = (fn) => { const { ctx, rects } = makeCtx(); fn(ctx); return new Set(rects.map((r) => r.fill)) } + assert.ok(fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.green)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.red)) + assert.ok(fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.red)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.green)) + const coinW = (t) => { const { ctx, rects } = makeCtx(); P.drawCoin(ctx, 0, 0, t); return rects[0].w } + assert.notEqual(coinW(0), coinW(1 / 6)) + const pans = (tilt) => { const { ctx, rects } = makeCtx(); P.drawScale(ctx, 0, 0, tilt); return rects.filter((r) => r.w === 15 && r.h === 6).map((r) => r.y) } + assert.equal(pans(0).length, 2); assert.notDeepEqual(pans(2), pans(-2)) +})