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

+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)
}
})