diff --git a/src/components/workers/engine/sprites.js b/src/components/workers/engine/sprites.js new file mode 100644 index 0000000..5060c55 --- /dev/null +++ b/src/components/workers/engine/sprites.js @@ -0,0 +1,284 @@ +// Engine-Grundlagen des Hustler-Hochhauses: Palette, Raster, Figuren mit Posen, Text, Sprechblasen. +// Portiert aus entwuerfe/sprites.js (ES5-IIFE mit fester Skala S = 3). Neu: Raster je Kontext statt fester Skala, +// Posen (stand, type, coffee, sleep), Graustufe für nicht angeschlossene Worker, Warnzeichen. +// Kein React, kein Appwrite, kein window/document: alles Zeichnen geht über den übergebenen Canvas-Kontext. + +export const PAL = Object.freeze({ + o: '#3a2f3d', // Umriss (dunkles Pflaume-Grau statt Schwarz) + b: '#d98a66', // Körper (Ton) + s: '#b8694a', // Körper Schatten + h: '#f2b897', // Körper Licht + e: '#2b2230', // Augen + w: '#ffffff', + g: '#9aa3b2', // Papier-Umriss + p: '#c3cad6', // Papier-Zeilen + ink: '#1a1f36', + mute: '#697386', + line: '#ebeef1', + floor: '#f6f7f9', + floor2: '#eef0f4', + wood: '#c9a27e', wood2: '#a8825f', + metal: '#c3c8d2', metal2: '#8c93a3', + screen: '#1f2a44', + red: '#d6455d', green: '#3aa66b', gold: '#e8b730', gold2: '#b98a12', + shadow: 'rgba(26,31,54,0.12)', +}) + +// Hut-Farben zeigen das Modell: Opus = Krone, Sonnet = Kappe, Haiku = Mütze, Overseer = Kommandomütze +export const HAT = Object.freeze({ + opus: Object.freeze(['#e8b730', '#b98a12']), + sonnet: Object.freeze(['#4a7fd6', '#2f5ea8']), + haiku: Object.freeze(['#5cbf8a', '#3a9466']), + overseer: Object.freeze(['#7a5cd6', '#54399f']), +}) + +// Ein Farbton pro Station/Team +export const TEAM = Object.freeze({ + radar: '#4a90d9', // Anfragen-Radar + aerzte: '#3fb6b2', // Ärzte & Praxen + handwerk: '#e8873a', // Handwerk + selbst: '#c66bd1', // Selbstständige + laden: '#8fb83a', // Läden & Gastro + bewertung: '#e0b13a', + werkstatt: '#d98a66', + pruefung: '#d6455d', + freigabe: '#7a5cd6', + archiv: '#8c93a3', + tresor: '#3aa66b', +}) + +export const FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace' + +// ---------- Raster ---------- + +// Raster je Kontext: k = Geräte-Pixel je logischer Einheit, (ox, oy) = Versatz in Geräte-Pixeln. +// Ohne setGrid gilt k = 3 (die Skala der Vorlage). Ein WeakMap hält den Zustand außerhalb des Kontexts. +const DEFAULT_GRID = Object.freeze({ k: 3, ox: 0, oy: 0 }) +const grids = new WeakMap() +const gridOf = (ctx) => grids.get(ctx) || DEFAULT_GRID + +export function setGrid(ctx, { k, ox = 0, oy = 0 }) { + grids.set(ctx, { k, ox, oy }) +} + +// Rechteck in logischen Einheiten. Beide Kanten werden einzeln gerundet (die rechte Kante eines Rechtecks ist die +// linke des nächsten: keine Nähte), die Größe beträgt mindestens ein Geräte-Pixel. +export function px(ctx, x, y, w, h, color) { + const { k, ox, oy } = gridOf(ctx) + const left = Math.round(x * k + ox) + const top = Math.round(y * k + oy) + ctx.fillStyle = color + ctx.fillRect( + left, + top, + Math.max(1, Math.round((x + w) * k + ox) - left), + Math.max(1, Math.round((y + h) * k + oy) - top), + ) +} + +// Fläche mit Deckkraft (save/restore gleichen sich aus) +export function tint(ctx, x, y, w, h, color, alpha) { + ctx.save() + ctx.globalAlpha = alpha + px(ctx, x, y, w, h, color) + ctx.restore() +} + +// ---------- Sprite-Karten ---------- + +const BODY = [ + '....oooo....', + '..oohhhboo..', + '.ohhbbbbbbo.', + '.ohbbbbbbbo.', + 'obbebbbbebso', + 'obbebbbbebso', + 'obbbbbbbbbso', + 'obbbbbbbbbso', + '.obbbbbbbso.', + '.ossbbbbsso.', + '..oossssoo..', +] +// Schlafen: die obere Augenzeile wird zu Körperfarbe, nur die Zeile darunter bleibt (zwei Augenpunkte) +const BODY_SLEEP = BODY.map((row, i) => (i === 4 ? row.replace(/e/g, 'b') : row)) +const LEGS = [ + ['...oo..oo...', '...oo..oo...'], // stehen + ['..oo....oo..', '.oo......oo.'], // Schritt A + ['....oooo....', '....oooo....'], // Schritt B +] +const HATS = { + opus: ['...H.HH.H...', '...HHHHHH...', '...SSSSSS...'], + sonnet: ['....HHHH....', '...HHHHHH...', '...SSSSSSSS.'], + haiku: ['.....HH.....', '...HHHHHH...', '...HSHSHSH..'], + overseer: ['...HHHHHH...', '..HHHHwHHH..', '..SSSSSSSS..'], +} +const VISOR = ['obVwVVVVVVso', 'obVVVVVVVVso'] // ersetzt die Augenzeilen 4–5 beim Overseer +const PAPER = ['gggggg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gggggg'] +const ARROW = ['ooooo', '.ooo.', '..o..'] +const ALERT = ['rrr', 'rrr', 'rrr', '.r.', '.r.', '...', '.r.'] // 3 × 7: „!“ + +// Umriss um eine Karte: jede leere Zelle (auch diagonal) neben einer belegten wird 'o'; die Karte wächst um 1 je Seite +function outlined(map) { + const h = map.length + const w = map[0].length + const at = (r, c) => (r >= 0 && r < h && c >= 0 && c < w ? map[r][c] : '.') + const nextToInk = (r, c) => [-1, 0, 1].some((dr) => [-1, 0, 1].some((dc) => at(r + dr, c + dc) !== '.')) + const rows = [] + for (let r = -1; r <= h; r++) { + let row = '' + for (let c = -1; c <= w; c++) row += at(r, c) === '.' && nextToInk(r, c) ? 'o' : at(r, c) + rows.push(row) + } + return rows +} +const ALERT_OUTLINED = outlined(ALERT) + +// Grautöne für nicht angeschlossene Worker (Umriss, Augen und Papier bleiben) +const GREY = { b: '#aeb3bd', s: '#8a909c', h: '#c9cdd5', H: '#9ca2ae', S: '#7f8693' } + +function workerColors(hat, grey) { + const hc = HAT[hat] || HAT.haiku + if (grey) return { o: PAL.o, b: GREY.b, s: GREY.s, h: GREY.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: GREY.H, S: GREY.S, V: GREY.S } + return { o: PAL.o, b: PAL.b, s: PAL.s, h: PAL.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: hc[0], S: hc[1], V: HAT.overseer[1] } +} + +// Zeichnet eine Karte zellenweise: (x, y) = linke obere Ecke in logischen Einheiten, sc = Zellgröße, flip spiegelt +function drawMap(ctx, map, x, y, colors, flip, sc) { + for (let r = 0; r < map.length; r++) { + const row = map[r] + for (let c = 0; c < row.length; c++) { + const ch = row[c] + if (ch === '.') continue + const col = colors[ch] + if (!col) continue + const cx = flip ? row.length - 1 - c : c + px(ctx, x + cx * sc, y + r * sc, sc, sc, col) + } + } +} + +function drawSelect(ctx, x, y, sc, t) { + const hop = Math.round(Math.sin(t * 4)) * sc + drawMap(ctx, ARROW, x, y + hop, { o: PAL.ink }, false, sc) +} + +// Kaffeetasse an der Hand (3 × 3) mit einem Dampfpixel, der mit t flackert +function drawCup(ctx, x, y, sc, flip, t) { + const cx = x + (flip ? -1 : 10) * sc + const cy = y + 6 * sc + px(ctx, cx, cy, 3 * sc, 3 * sc, PAL.metal2) + px(ctx, cx, cy, 3 * sc, sc, PAL.wood2) + if (Math.floor(t * 3) % 2 === 0) px(ctx, cx + sc, cy - 2 * sc, sc, sc, PAL.g) +} + +// Kleines „z“ aus drei Rechtecken über dem Hut, steigt in ganzen Pixeln mit t (alle 2,5 s von vorn) +function drawSleepZ(ctx, x, y, sc, flip, t) { + const rise = Math.floor((Math.max(0, t) * 2) % 5) + const zx = x + (flip ? 0 : 9) * sc + const zy = y - (6 + rise) * sc + px(ctx, zx, zy, 3 * sc, sc, PAL.mute) + px(ctx, zx + sc, zy + sc, sc, sc, PAL.mute) + px(ctx, zx, zy + 2 * sc, 3 * sc, sc, PAL.mute) +} + +/* Zeichnet einen kleinen Worker. (x, y) = linke obere Ecke des Körpers (ohne Hut), logische Einheiten. + hat: 'opus' | 'sonnet' | 'haiku' | 'overseer'; frame: 0 stehen, 1/2 laufen; facing: 1 rechts, -1 links; + carrying: Dokument in der Hand; scale: 1 normal, 2 groß; selected: Pfeil über dem Hut; + pose: 'stand' und 'type' sehen gleich aus (das Wippen macht die Regie), 'coffee' mit Tasse, 'sleep' mit z; + grey: halb durchsichtig in Grautönen (nicht angeschlossen), t: Zeit in Sekunden für z, Dampf und Pfeil. + Rückgabe: Trefferfläche { x, y, w, h } in logischen Einheiten (bei Graustufe unverändert). */ +export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 } = {}) { + const sc = scale + const flip = facing === -1 + const bob = frame === 1 ? -1 * sc : 0 + const colors = workerColors(hat, grey) + + if (grey) { + ctx.save() + ctx.globalAlpha = 0.55 + } + px(ctx, x + 2 * sc, y + 13 * sc, 8 * sc, 1 * sc, PAL.shadow) + drawMap(ctx, LEGS[frame] || LEGS[0], x, y + 11 * sc, colors, flip, sc) + drawMap(ctx, pose === 'sleep' ? BODY_SLEEP : BODY, x, y + bob, colors, flip, sc) + if (hat === 'overseer') drawMap(ctx, VISOR, x, y + bob + 4 * sc, colors, flip, sc) + drawMap(ctx, HATS[hat] || HATS.haiku, x, y + bob - 2 * sc, colors, flip, sc) + if (carrying) drawMap(ctx, PAPER, x + (flip ? -2 : 8) * sc, y + bob + 6 * sc, colors, false, sc) + if (pose === 'coffee') drawCup(ctx, x, y + bob, sc, flip, t) + else if (pose === 'sleep') drawSleepZ(ctx, x, y + bob, sc, flip, t) + if (grey) ctx.restore() + + if (selected) drawSelect(ctx, x + 3.5 * sc, y + bob - 7 * sc, sc, t) + return { x: x - (carrying && flip ? 2 * sc : 0), y: y - 2 * sc, w: (carrying ? 14 : 12) * sc, h: 16 * sc } +} + +export function drawPaper(ctx, x, y) { + drawMap(ctx, PAPER, x, y, { g: PAL.g, w: PAL.w, p: PAL.p }, false, 1) +} + +// Rotes „!“ mit Umriss (bleibt stehen, solange ein Worker den Status fehler hat). +// (x, y) = Mitte der Unterkante, wie die Spitze der Sprechblase: das Zeichen steht über diesem Punkt. +export function drawAlertMark(ctx, x, y) { + const cols = ALERT_OUTLINED[0].length + drawMap(ctx, ALERT_OUTLINED, x - cols / 2, y - ALERT_OUTLINED.length, { r: PAL.red, o: PAL.o }, false, 1) +} + +// ---------- Text (Pixel-Schrift, Größe wächst mit dem Raster) ---------- + +const fontOf = (weight, devPx) => `${weight} ${Math.round(devPx * 100) / 100}px ${FONT_FAMILY}` + +/* Text an (x, y) in logischen Einheiten (y = Oberkante der Zeile). Schrift = size * k / 3 Geräte-Pixel, + bei k = 3 also size px. bg: Farbe eines Kastens hinter dem Text; align: 'left' | 'center' | 'right'. */ +export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg } = {}) { + const { k, ox, oy } = gridOf(ctx) + const f = k / 3 + const str = String(text ?? '') + const dx = Math.round(x * k + ox) + const dy = Math.round(y * k + oy) + ctx.font = fontOf(weight, size * f) + ctx.textBaseline = 'top' + ctx.textAlign = align + if (bg) { + const tw = ctx.measureText(str).width + const left = align === 'center' ? dx - tw / 2 : align === 'right' ? dx - tw : dx + ctx.fillStyle = bg + ctx.fillRect(Math.round(left - 4 * f), Math.round(dy - 2 * f), Math.round(tw + 8 * f), Math.round(size * f + 5 * f)) + } + ctx.fillStyle = color + ctx.fillText(str, dx, dy) + ctx.textAlign = 'left' +} + +function deviceRect(ctx, x, y, w, h, color) { + ctx.fillStyle = color + ctx.fillRect(x, y, w, h) +} + +/* Sprechblase über einem Worker: (x, y) = Spitze der Blase (logisch). Maße wachsen mit dem Raster; die Blase + wird in Geräte-Pixeln in die Breite von ctx.canvas geklemmt (Rand 2 bei k = 3, ohne Canvas 960). */ +export function drawBubble(ctx, x, y, text) { + const str = String(text ?? '') + if (str === '') return + const { k, ox, oy } = gridOf(ctx) + const f = k / 3 + const u = (n) => Math.max(1, Math.round(n * f)) + ctx.font = fontOf(500, 12 * f) + ctx.textBaseline = 'top' + ctx.textAlign = 'left' + const pad = u(7) + const edge = u(2) + const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad + const bh = u(22) + const tipX = Math.round(x * k + ox) + const by = Math.round(y * k + oy) - bh - u(6) + const maxW = ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960 + const bx = Math.max(edge, Math.min(maxW - bw - edge, Math.round(tipX - bw / 2))) + // Die Spitze bleibt am Kasten, auch wenn der Kasten wegen des Rands verschoben wurde + const tailW = u(6) + const tx = Math.max(bx + Math.round(tailW / 2), Math.min(bx + bw - Math.round(tailW / 2), tipX)) + deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o) + deviceRect(ctx, bx, by, bw, bh, PAL.w) + deviceRect(ctx, tx - Math.round(tailW / 2), by + bh + edge, tailW, u(3), PAL.o) + deviceRect(ctx, tx - u(1), by + bh + edge + u(3), u(2), u(2), PAL.o) + ctx.fillStyle = PAL.ink + ctx.fillText(str, bx + pad, by + u(5)) +} diff --git a/tests/unit/helpers/fakeCtx.mjs b/tests/unit/helpers/fakeCtx.mjs new file mode 100644 index 0000000..b00599e --- /dev/null +++ b/tests/unit/helpers/fakeCtx.mjs @@ -0,0 +1,42 @@ +// Aufzeichnender Canvas-Kontext für die Engine-Tests (Muster: Proxy aus entwuerfe/smoke.js). +// fillRect/fillText werden mit dem aktuellen fillStyle/font notiert, alles Übrige ist ein No-op. + +/** + * makeCtx({ width, height }) → { ctx, rects, texts, counts, alphas } + * rects: [{ x, y, w, h, fill }] jedes fillRect + * texts: [{ text, x, y, font }] jedes fillText (Nicht-String wirft wie im Smoke-Test) + * counts: { save, restore } Aufrufe von ctx.save()/ctx.restore() + * alphas: [] jede Zuweisung an ctx.globalAlpha + */ +export function makeCtx({ width = 960, height = 768 } = {}) { + const rects = [] + const texts = [] + const counts = { save: 0, restore: 0 } + const alphas = [] + const target = { canvas: { width, height }, fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1 } + const ctx = new Proxy(target, { + get(t, prop) { + if (typeof prop === 'symbol') return undefined + if (prop === 'save') return () => { counts.save++ } + if (prop === 'restore') return () => { counts.restore++ } + if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 }) + if (prop === 'fillRect') return (x, y, w, h) => { rects.push({ x, y, w, h, fill: t.fillStyle }) } + if (prop === 'fillText') { + return (text, x, y) => { + if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text) + texts.push({ text, x, y, font: t.font }) + } + } + if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} }) + if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) }) + if (prop in t) return t[prop] + return () => {} + }, + set(t, prop, value) { + if (prop === 'globalAlpha') alphas.push(value) + t[prop] = value + return true + }, + }) + return { ctx, rects, texts, counts, alphas } +} diff --git a/tests/unit/workersSprites.test.mjs b/tests/unit/workersSprites.test.mjs new file mode 100644 index 0000000..2b67549 --- /dev/null +++ b/tests/unit/workersSprites.test.mjs @@ -0,0 +1,140 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +import { makeCtx } from './helpers/fakeCtx.mjs' + +// Engine-Grundlagen: Raster, Figuren mit Posen, Text, Sprechblasen +const S = await loadModule('src/components/workers/engine/sprites.js') + +test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => { + let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 }) + S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222') + assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x) + ;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12]) + ;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1) +}) +test('label: Schriftgröße wächst mit dem Raster', () => { + const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/) +}) +test('drawWorker: Trefferfläche', () => { + const { ctx } = makeCtx() + assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 }) + assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14) + assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 }) +}) +test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => { + for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) } + const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length } + assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2) + for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) } + const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore) +}) +test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => { + const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0) + const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960) +}) + +// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ---- + +test('Raster gilt je Kontext (WeakMap, kein Feld am Kontext); ohne setGrid gilt k = 3', () => { + const a = makeCtx() + const b = makeCtx() + S.setGrid(a.ctx, { k: 6, ox: 1, oy: 2 }) + S.px(a.ctx, 1, 1, 1, 1, '#000') + S.px(b.ctx, 1, 1, 1, 1, '#000') + assert.deepEqual([a.rects[0].x, a.rects[0].y, a.rects[0].w, a.rects[0].h], [7, 8, 6, 6]) + assert.deepEqual([b.rects[0].x, b.rects[0].y, b.rects[0].w, b.rects[0].h], [3, 3, 3, 3]) + assert.equal('wkGrid' in a.ctx, false) +}) +test('tint: Alpha nur um die Fläche, save und restore ausgeglichen', () => { + const { ctx, rects, alphas, counts } = makeCtx() + S.tint(ctx, 0, 0, 2, 2, '#ff0000', 0.25) + assert.deepEqual([rects.length, rects[0].fill, rects[0].w], [1, '#ff0000', 6]) + assert.deepEqual(alphas, [0.25]) + assert.deepEqual(counts, { save: 1, restore: 1 }) +}) +test('label: Zahl wird zu Text, Hintergrund folgt der Ausrichtung', () => { + const n = makeCtx() + S.label(n.ctx, 42, 1, 1) + assert.equal(n.texts[0].text, '42') + const c = makeCtx() + S.label(c.ctx, 'abcd', 10, 5, { size: 12, align: 'center', bg: '#ffffff' }) + // Breite 4 * 7 = 28, Mitte bei 30: Kasten von 30 - 14 - 4 bis + 28 + 8, Höhe 12 + 5 + assert.deepEqual([c.rects[0].x, c.rects[0].y, c.rects[0].w, c.rects[0].h, c.rects[0].fill], [12, 13, 36, 17, '#ffffff']) + assert.equal(c.texts[0].x, 30) +}) +test('drawWorker: Schlafen zeigt ein z über dem Hut, das mit t steigt; stehen nicht', () => { + const zs = (pose, t) => { + const { ctx, rects } = makeCtx() + S.drawWorker(ctx, 20, 40, { hat: 'haiku', pose, t }) + return rects.filter((r) => r.fill === S.PAL.mute) + } + const z0 = zs('sleep', 0) + const z1 = zs('sleep', 0.5) + assert.equal(z0.length, 3) + assert.equal(zs('stand', 0).length, 0) + assert.ok(z1[0].y < z0[0].y, 'steigt') + assert.ok(Math.max(...z0.map((r) => r.y + r.h)) <= (40 - 2) * 3, 'über dem Hut') +}) +test('drawWorker: Kaffee zeichnet eine 3x3-Tasse und einen flackernden Dampfpixel', () => { + const run = (pose, t) => { + const { ctx, rects } = makeCtx() + S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t }) + return rects + } + const cups = (rects) => rects.filter((r) => r.fill === S.PAL.metal2 && r.w === 9 && r.h === 9).length + const steam = (rects) => rects.filter((r) => r.fill === S.PAL.g).length + assert.equal(cups(run('coffee', 0)), 1) + assert.equal(cups(run('stand', 0)), 0) + assert.deepEqual([steam(run('coffee', 0)), steam(run('coffee', 0.4)), steam(run('stand', 0))], [1, 0, 0]) +}) +test('drawWorker: Grau tauscht Körper- und Hutfarben, Trefferfläche bleibt', () => { + const grey = makeCtx() + const hit = S.drawWorker(grey.ctx, 10, 20, { hat: 'opus', grey: true, carrying: true }) + assert.deepEqual(hit, S.drawWorker(makeCtx().ctx, 10, 20, { hat: 'opus', carrying: true })) + const own = [S.PAL.b, S.PAL.s, S.PAL.h, S.HAT.opus[0], S.HAT.opus[1]] + assert.ok(!grey.rects.some((r) => own.includes(r.fill))) + assert.deepEqual([grey.alphas, grey.counts], [[0.55], { save: 1, restore: 1 }]) +}) +test('drawWorker: Auswahlpfeil nur bei selected, außerhalb der Graustufe', () => { + const ink = (opts) => { + const { ctx, rects, alphas } = makeCtx() + S.drawWorker(ctx, 0, 0, { hat: 'haiku', ...opts }) + return [rects.filter((r) => r.fill === S.PAL.ink).length, alphas.length] + } + assert.deepEqual(ink({}), [0, 0]) + assert.deepEqual(ink({ selected: true }), [9, 0]) + assert.deepEqual(ink({ selected: true, grey: true }), [9, 1]) +}) +test('drawAlertMark: rotes 3x7-Ausrufezeichen mit Umriss, unten mittig bei (x, y)', () => { + const { ctx, rects } = makeCtx() + S.drawAlertMark(ctx, 50, 30) + assert.deepEqual([...new Set(rects.map((r) => r.fill))].sort(), [S.PAL.o, S.PAL.red].sort()) + const red = rects.filter((r) => r.fill === S.PAL.red) + const left = Math.min(...red.map((r) => r.x)) + const right = Math.max(...red.map((r) => r.x + r.w)) + const top = Math.min(...red.map((r) => r.y)) + const bottom = Math.max(...red.map((r) => r.y + r.h)) + assert.deepEqual([right - left, bottom - top], [9, 21]) + assert.ok(Math.abs((left + right) / 2 - 150) <= 1, 'mittig') + assert.equal(Math.max(...rects.map((r) => r.y + r.h)), 90) +}) +test('drawBubble: Text und Maße folgen dem Raster, leerer Text zeichnet nichts, Spitze außerhalb bleibt im Bild', () => { + const a = makeCtx() + S.drawBubble(a.ctx, 50, 40, 'Hallo') + assert.deepEqual([a.texts.length, a.texts[0].text], [1, 'Hallo']) + assert.match(a.texts[0].font, /12px/) + const b = makeCtx() + S.setGrid(b.ctx, { k: 6 }) + S.drawBubble(b.ctx, 50, 40, 'Hallo') + assert.match(b.texts[0].font, /24px/) + const e = makeCtx() + S.drawBubble(e.ctx, 50, 40, '') + assert.equal(e.rects.length, 0) + const o = makeCtx() + S.drawBubble(o.ctx, 330, 30, 'Hallo') + assert.ok(Math.max(...o.rects.map((q) => q.x + q.w)) <= 960) + const small = makeCtx({ width: 100 }) + S.drawBubble(small.ctx, 20, 30, 'Hallo') + assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 100) +})