Workers P2: Effekt-System mit Obergrenze
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c27f0b600b
commit
38261ecdd8
2 files changed
+350
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user