Workers P2: Effekt-System mit Obergrenze

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 02:10:37 +00:00
1 parent c27f0b600b
commit 38261ecdd8
2 files changed
+350

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
// Effekt-System des Hustler-Hochhauses: kurzlebige Zeichnungen (Papier, Brief, Münze, Stempel, Funkeln, Warnzeichen),
// die in Weltkoordinaten von einem Punkt zum anderen laufen. Portiert aus entwuerfe/scene-turm.js (addFx/updateFx/drawFx/envelope).
// Zeit läuft nur über update(dt) der Szene, nie über setTimeout (pausierte Tabs laufen so nicht vor).
// Kein React, kein Appwrite, kein window/document; gezeichnet wird ausschließlich über die Engine-Helfer (px, drawPaper, ...).
import { px, PAL, drawPaper, drawAlertMark } from './sprites.js'
import { drawCoin, drawStamp } from './props.js'
export const FX_NAMES = Object.freeze(['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
export const MAX_FX = 80
// ---------- Liste der laufenden Effekte ----------
/* createFx() → { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() }
add: Lebensdauer und Verzögerung in Sekunden; draw(ctx, progress 0..1, effect) mit effect = { t, life } (t = Sekunden seit dem Start).
onEnd läuft genau einmal beim Ablauf. Ist die Liste mit MAX_FX Effekten voll, wird der neue verworfen und sein onEnd sofort gerufen.
clear() verwirft alles ohne onEnd. size() zählt auch Effekte, die noch auf ihre Verzögerung warten. */
export function createFx() {
let list = []
return {
add(life, draw, onEnd, delay = 0) {
if (list.length >= MAX_FX) {
if (typeof onEnd === 'function') onEnd()
return
}
list.push({
t: Number.isFinite(delay) && delay > 0 ? -delay : 0, // negativ = wartet noch auf den Start
life: Number.isFinite(life) && life > 0 ? life : 0,
draw,
onEnd,
})
},
update(dt) {
if (!Number.isFinite(dt) || dt <= 0) return
const alive = []
const ended = []
for (const f of list) {
f.t += dt
if (f.t >= f.life) ended.push(f)
else alive.push(f)
}
// Erst die Liste ersetzen, dann die Rückrufe: Ein onEnd darf neue Effekte anlegen oder clear() rufen.
list = alive
let failure = null
for (const f of ended) {
if (typeof f.onEnd !== 'function') continue
try {
f.onEnd()
} catch (err) {
if (!failure) failure = { err } // die übrigen onEnd laufen trotzdem, der erste Fehler wird danach geworfen
}
}
if (failure) throw failure.err
},
draw(ctx) {
for (const f of list) {
if (f.t < 0 || typeof f.draw !== 'function') continue
f.draw(ctx, f.life > 0 ? Math.min(1, f.t / f.life) : 1, f)
}
},
clear() {
list = []
},
size() {
return list.length
},
}
}
// ---------- Zeichnungen ----------
// Brief 7 × 5 (x, y = linke obere Ecke)
const ENVELOPE_COLOR = '#7a5cd6'
function drawEnvelope(ctx, x, y) {
px(ctx, x, y, 7, 5, ENVELOPE_COLOR)
px(ctx, x + 1, y + 1, 5, 3, PAL.w)
px(ctx, x + 1, y + 1, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 2, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 3, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 4, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 5, y + 1, 1, 1, ENVELOPE_COLOR)
}
// Vier Goldpixel in den Ecken wachsen nach außen, ein heller Pixel steht oben (um (x, y) als Mitte)
function drawSparkle(ctx, x, y, p) {
const s = Math.round(p * 6)
px(ctx, x - s - 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x - s - 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x, y - s - 4, 1, 1, '#fff3c4')
}
// Punkt auf dem Bogen von a nach b, in ganzen Einheiten (Pixel-Optik); high = Höhe des Bogens über der Geraden
function arcPoint(a, b, p, high) {
return {
x: Math.round(a.x + (b.x - a.x) * p),
y: Math.round(a.y + (b.y - a.y) * p - Math.sin(p * Math.PI) * high),
}
}
// Ziel um len Einheiten vor b (auf der Strecke a nach b); liegt b näher als len, bleibt der Start
function shortenedEnd(a, b, len) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.hypot(dx, dy)
if (dist <= len) return a
return { x: b.x - (dx / dist) * len, y: b.y - (dy / dist) * len }
}
// Der Stempel hüpft dreimal über dem Punkt, die Sprünge werden flacher, am Ende liegt er auf
function stampDraw(a, ok) {
return (ctx, p) => {
const hop = Math.round(Math.abs(Math.sin(p * Math.PI * 3)) * 6 * (1 - p))
drawStamp(ctx, Math.round(a.x) - 3, Math.round(a.y) - 5 - hop, ok)
}
}
/* Je Effektname: Lebensdauer in Sekunden und eine Fabrik, die aus Start (a) und Ziel (b) die Zeichenfunktion baut.
Papier, Brief und Münze sind auf ihren Punkt zentriert (Mitte des Sprites). Stempel und Warnzeichen brauchen nur a und stehen mit
der Unterkante darauf (wie die Spitze der Sprechblase: die Regie gibt die Kopfoberkante des Workers), das Funkeln ist um a zentriert. */
const KINDS = {
paper: {
life: 0.9,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 8)
drawPaper(ctx, q.x - 3, q.y - 3)
},
},
envelope: {
life: 3.4,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 12)
drawEnvelope(ctx, q.x - 3, q.y - 2)
},
},
coin: {
life: 0.7,
make: (a, b) => (ctx, p, f) => {
const q = arcPoint(a, b, p, 14)
drawCoin(ctx, q.x - 2, q.y - 2, f.t)
},
},
stampOk: { life: 1.2, make: (a) => stampDraw(a, true) },
stampNo: { life: 1.2, make: (a) => stampDraw(a, false) },
sparkle: {
life: 1.0,
make: (a) => (ctx, p) => drawSparkle(ctx, Math.round(a.x), Math.round(a.y), p),
},
alert: {
life: 1.5,
// Das Zeichen blinkt (vier Wechsel je Sekunde) und beginnt sichtbar; a = Kopfoberkante des Workers
make: (a) => (ctx, p, f) => {
if (Math.floor(f.t * 4) % 2 === 0) drawAlertMark(ctx, Math.round(a.x), Math.round(a.y))
},
},
drawer: {
life: 0.9,
// Das Blatt verschwindet in der Lade: es endet 3 Einheiten vor dem Ziel
make: (a, b) => {
const end = shortenedEnd(a, b, 3)
return (ctx, p) => {
const q = arcPoint(a, end, p, 6)
drawPaper(ctx, q.x - 3, q.y - 3)
}
},
},
}
/* Startet den benannten Effekt in Weltkoordinaten. from/to = { x, y }; fehlt eines, gilt das andere, fehlen beide, (0, 0).
delay in Sekunden, onEnd genau einmal beim Ablauf. Unbekannter Name oder volle Liste: nichts wird gezeichnet,
onEnd läuft sofort (damit eine Regie, die darauf wartet, nicht hängen bleibt). */
export function spawnFx(fx, name, opts) {
const { from, to, delay = 0, onEnd } = opts || {}
const kind = FX_NAMES.includes(name) ? KINDS[name] : null
if (!kind) {
if (typeof onEnd === 'function') onEnd()
return
}
const a = from || to || { x: 0, y: 0 }
const b = to || a
fx.add(kind.life, kind.make(a, b), onEnd, delay)
}
+162
View File
@@ -0,0 +1,162 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Effekt-System: Verzögerung, Lebensdauer, Obergrenze und die acht benannten Effekte
const F = await loadModule('src/components/workers/engine/fx.js')
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
test('Verzögerung, Lebensdauer, onEnd genau einmal', () => {
const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5)
const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0)
fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw')
fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0)
})
test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => {
const fx = F.createFx(); let ended = 0
for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ })
assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120)
})
test('jeder Effektname zeichnet und endet in höchstens 6 s', () => {
for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false
F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } })
for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) }
assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) }
})
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
const K = 3 // Standardraster der Engine (kein setGrid): Geräte-Pixel = 3 × logische Einheiten
const LIFE = { paper: 0.9, envelope: 3.4, coin: 0.7, stampOk: 1.2, stampNo: 1.2, sparkle: 1.0, alert: 1.5, drawer: 0.9 }
// Umschließendes Rechteck aller Rechtecke in logischen Einheiten (null ohne Rechtecke)
function bounds(rects) {
if (!rects.length) return null
const x0 = Math.min(...rects.map((r) => r.x)) / K, y0 = Math.min(...rects.map((r) => r.y)) / K
const x1 = Math.max(...rects.map((r) => r.x + r.w)) / K, y1 = Math.max(...rects.map((r) => r.y + r.h)) / K
return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 }
}
// Ein Bild des Effekts nach `at` Sekunden (frischer Kontext)
function shot(name, opts, at) {
const fx = F.createFx(), { ctx, rects } = makeCtx()
F.spawnFx(fx, name, opts)
if (at > 0) fx.update(at)
fx.draw(ctx)
return { b: bounds(rects), rects, fx }
}
const FROM = { x: 40, y: 100 }
test('FX_NAMES und MAX_FX wie vereinbart, die Liste ist eingefroren', () => {
assert.deepEqual([...F.FX_NAMES], ['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
assert.ok(Object.isFrozen(F.FX_NAMES)); assert.equal(F.MAX_FX, 80)
})
test('Lebensdauer je Effekt: kurz vor dem Ende noch da, kurz danach weg und onEnd genau einmal', () => {
for (const name of F.FX_NAMES) {
const fx = F.createFx(); let ended = 0
F.spawnFx(fx, name, { from: FROM, to: { x: 140, y: 100 }, onEnd: () => { ended++ } })
fx.update(LIFE[name] - 0.05); assert.equal(fx.size(), 1, name); assert.equal(ended, 0, name)
fx.update(0.1); assert.equal(fx.size(), 0, name); assert.equal(ended, 1, name)
fx.update(1); assert.equal(ended, 1, name)
}
})
test('draw bekommt Fortschritt 0..1 (nie darüber) und den Effekt mit Zeit und Lebensdauer', () => {
const fx = F.createFx(), { ctx } = makeCtx(), seen = []
fx.add(1, (c, p, f) => seen.push([p, f.t, f.life]))
fx.draw(ctx); for (let i = 0; i < 9; i++) { fx.update(0.1); fx.draw(ctx) }
assert.equal(seen.length, 10); assert.deepEqual(seen[0], [0, 0, 1])
for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] >= seen[i - 1][0] && seen[i][0] <= 1)
assert.ok(seen.at(-1)[0] > 0.85)
const solo = F.createFx(); let p = null; solo.add(0, (c, q) => { p = q }); solo.draw(ctx); assert.equal(p, 1) // Lebensdauer 0: kein NaN
})
test('update ignoriert dt, das keine positive endliche Zahl ist (kein NaN im Zeitstand)', () => {
const fx = F.createFx(); let ended = 0; fx.add(1, () => {}, () => { ended++ })
fx.update(NaN); fx.update(-5); fx.update(0); fx.update(Infinity); fx.update(undefined)
assert.equal(fx.size(), 1); fx.update(1.1); assert.equal(fx.size(), 0); assert.equal(ended, 1)
})
test('ungültige Lebensdauer und Verzögerung laufen höchstens bis zum nächsten update', () => {
const fx = F.createFx(); fx.add(NaN, () => {}); fx.add(-3, () => {}); fx.add(1, () => {}, undefined, NaN); fx.add(1, () => {}, undefined, -2)
assert.equal(fx.size(), 4); fx.update(0.1); assert.equal(fx.size(), 2)
})
test('ein onEnd darf neue Effekte anlegen und clear() rufen; clear mitten im update wirft nicht', () => {
const fx = F.createFx(), log = []
fx.add(0.5, () => {}, () => { log.push('a'); fx.add(0.5, () => {}, () => log.push('b')) })
fx.update(0.6); assert.equal(fx.size(), 1); assert.deepEqual(log, ['a'])
fx.update(0.6); assert.deepEqual(log, ['a', 'b']); assert.equal(fx.size(), 0)
fx.add(0.5, () => {}, () => { log.push('c'); fx.clear() }); fx.add(5, () => {}, () => log.push('nie'))
fx.update(0.6); assert.equal(fx.size(), 0); assert.deepEqual(log, ['a', 'b', 'c'])
})
test('wirft ein onEnd, laufen die übrigen trotzdem und der erste Fehler kommt danach', () => {
const fx = F.createFx(); let second = 0
fx.add(0.1, () => {}, () => { throw new Error('boom') }); fx.add(0.1, () => {}, () => { second++ }); fx.add(5, () => {})
assert.throws(() => fx.update(0.2), /boom/); assert.equal(second, 1); assert.equal(fx.size(), 1)
})
test('voll: ein verworfener Effekt meldet onEnd sofort (nicht erst beim nächsten update)', () => {
const fx = F.createFx(); for (let i = 0; i < F.MAX_FX; i++) fx.add(5, () => {})
let ended = 0; F.spawnFx(fx, 'coin', { from: FROM, onEnd: () => { ended++ } })
assert.equal(ended, 1); assert.equal(fx.size(), F.MAX_FX)
})
test('spawnFx: Verzögerung zeichnet erst danach; unbekannter Name wirft nicht und meldet onEnd sofort', () => {
const fx = F.createFx(), { ctx, rects } = makeCtx()
F.spawnFx(fx, 'paper', { from: FROM, to: { x: 140, y: 100 }, delay: 0.5 })
fx.update(0.4); fx.draw(ctx); assert.equal(rects.length, 0)
fx.update(0.3); fx.draw(ctx); assert.ok(rects.length > 0)
let ended = 0; const empty = F.createFx()
for (const bad of ['gibtsnicht', '', undefined, null, 'constructor', '__proto__']) F.spawnFx(empty, bad, { from: FROM, onEnd: () => { ended++ } })
assert.equal(ended, 6); assert.equal(empty.size(), 0)
F.spawnFx(empty, 'sparkle'); assert.equal(empty.size(), 1) // ohne from/to und ohne Optionen: Standardpunkt statt Wurf
})
test('Bogenflug: Papier 8, Brief 12, Münze 14 Einheiten über der Geraden, Start und Ziel liegen auf der Geraden', () => {
const to = { x: 140, y: 100 } // gleiche Höhe: jede Abweichung in y ist der Bogen
for (const [name, high] of [['paper', 8], ['envelope', 12], ['coin', 14]]) {
const start = shot(name, { from: FROM, to }, 0).b, mid = shot(name, { from: FROM, to }, LIFE[name] / 2).b, end = shot(name, { from: FROM, to }, LIFE[name] - 0.001).b
assert.ok(Math.abs(start.cy - mid.cy - high) <= 1, `${name} Bogenhöhe ${start.cy - mid.cy}`)
assert.ok(Math.abs(start.cy - end.cy) <= 1, `${name} Ende auf der Geraden`)
assert.ok(Math.abs(start.cx - FROM.x) <= 1, `${name} Start bei from`); assert.ok(Math.abs(mid.cx - 90) <= 1, `${name} Mitte`); assert.ok(Math.abs(end.cx - to.x) <= 1, `${name} Ende bei to`)
}
})
test('Bogenflug folgt auch einem schrägen Weg (Ziel oben rechts)', () => {
const to = { x: 140, y: 60 }, start = shot('paper', { from: FROM, to }, 0).b, end = shot('paper', { from: FROM, to }, 0.899).b
assert.ok(end.cx - start.cx >= 98); assert.ok(start.cy - end.cy >= 38)
})
test('Briefumschlag ist violett mit weißem Fenster, Münze golden, Papier grau-weiß', () => {
const fills = (name) => new Set(shot(name, { from: FROM, to: { x: 140, y: 100 } }, 0.2).rects.map((r) => r.fill))
assert.ok(fills('envelope').has('#7a5cd6') && fills('envelope').has(PAL.w))
assert.ok(fills('coin').has(PAL.gold2)); assert.ok(fills('paper').has(PAL.g) && fills('paper').has(PAL.w))
})
test('Schubladen-Papier endet 3 Einheiten vor dem Ziel, das normale Papier erreicht es', () => {
const to = { x: 140, y: 100 }, d = shot('drawer', { from: FROM, to }, 0.899).b, p = shot('paper', { from: FROM, to }, 0.899).b
assert.ok(Math.abs(d.cx - (to.x - 3)) <= 1, `drawer ${d.cx}`); assert.ok(Math.abs(p.cx - to.x) <= 1, `paper ${p.cx}`)
// liegt das Ziel näher als 3 Einheiten, bleibt das Blatt beim Start (kein Rückwärtsflug, kein NaN)
const near = shot('drawer', { from: FROM, to: { x: 41, y: 100 } }, 0.899).b; assert.ok(Math.abs(near.cx - FROM.x) <= 1)
const same = shot('drawer', { from: FROM, to: FROM }, 0.4).b; assert.ok(Number.isFinite(same.cx))
})
test('Stempel: grün für OK, rot für Nein, hüpft über dem Punkt und landet darauf', () => {
for (const [name, color, other] of [['stampOk', PAL.green, PAL.red], ['stampNo', PAL.red, PAL.green]]) {
const heights = []
for (let i = 0; i < 12; i++) {
const s = shot(name, { from: FROM }, i * 0.1); assert.ok(s.b.y1 <= FROM.y + 0.01, `${name} bleibt über from`)
assert.ok(s.rects.some((r) => r.fill === color), name); assert.ok(!s.rects.some((r) => r.fill === other), name); heights.push(s.b.y1)
}
assert.ok(Math.min(...heights) < FROM.y - 3, `${name} hüpft`); assert.ok(Math.abs(shot(name, { from: FROM }, 1.199).b.y1 - FROM.y) <= 1, `${name} landet`)
assert.ok(Math.abs(shot(name, { from: FROM }, 0.5).b.cx - FROM.x) <= 1, `${name} mittig`)
}
})
test('Funkeln: Goldpixel wachsen nach außen und bleiben um from', () => {
const early = shot('sparkle', { from: FROM }, 0.05).b, late = shot('sparkle', { from: FROM }, 0.95).b
assert.ok(late.x1 - late.x0 > early.x1 - early.x0 + 6); assert.ok(Math.abs(late.cx - FROM.x) <= 1)
assert.ok(shot('sparkle', { from: FROM }, 0.5).rects.every((r) => r.fill === PAL.gold || r.fill === '#fff3c4'))
})
test('Warnzeichen: blinkt, beginnt sichtbar, steht mit der Unterkante auf from und ist rot', () => {
const on = []
for (let i = 0; i < 30; i++) on.push(shot('alert', { from: FROM }, i * 0.05).rects.length > 0)
assert.equal(on[0], true); assert.ok(on.some((v) => !v)); assert.ok(on.filter(Boolean).length >= 10)
const b = shot('alert', { from: FROM }, 0).b; assert.equal(b.y1, FROM.y); assert.ok(Math.abs(b.cx - FROM.x) <= 1)
assert.ok(shot('alert', { from: FROM }, 0).rects.some((r) => r.fill === PAL.red))
})
test('mit nur to oder nur from werden die Eintragspunkte ersetzt (kein Wurf, kein NaN)', () => {
for (const name of F.FX_NAMES) {
const a = shot(name, { to: { x: 90, y: 50 } }, 0).b, c = shot(name, { from: { x: 90, y: 50 } }, 0).b
assert.ok(a && Number.isFinite(a.cx) && Number.isFinite(a.cy), name); assert.ok(c && Number.isFinite(c.cx) && Number.isFinite(c.cy), name)
}
})