claude-mcp-workflow 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +4 -2
- package/README.md +39 -2
- package/build/engine.d.ts +21 -1
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +59 -6
- package/build/engine.js.map +1 -1
- package/build/storage.d.ts +20 -0
- package/build/storage.d.ts.map +1 -1
- package/build/storage.js +41 -0
- package/build/storage.js.map +1 -1
- package/build/types.d.ts +21 -20
- package/build/types.d.ts.map +1 -1
- package/build/types.js +2 -1
- package/build/types.js.map +1 -1
- package/hooks/hooks.json +4 -2
- package/hooks/workflow-cleanup.sh +58 -20
- package/hooks/workflow-start.sh +60 -32
- package/package.json +1 -1
- package/templates/bug-fix.yaml +98 -16
- package/templates/code-review.yaml +30 -12
- package/templates/coding.yaml +49 -4
- package/templates/debugging.yaml +39 -14
- package/templates/explore.yaml +48 -14
- package/templates/file-code.yaml +13 -4
- package/templates/file-review.yaml +25 -6
- package/templates/github-init.yaml +24 -8
- package/templates/investigate.yaml +13 -4
- package/templates/master.yaml +16 -13
- package/templates/new-feature.yaml +24 -26
- package/templates/planning.yaml +38 -7
- package/templates/reflection.yaml +11 -4
- package/templates/review-push.yaml +26 -7
- package/templates/skills/architecture/SKILL.md +131 -1
- package/templates/skills/aws-lambda/SKILL.md +9 -9
- package/templates/skills/browser-verify/SKILL.md +58 -0
- package/templates/skills/build-cmake/SKILL.md +24 -8
- package/templates/skills/ci-github-actions/SKILL.md +34 -18
- package/templates/skills/claude-code-config/SKILL.md +41 -19
- package/templates/skills/cleanup/SKILL.md +57 -0
- package/templates/skills/coding-skill-selector/SKILL.md +2 -1
- package/templates/skills/debug-bridge-scaffold/SKILL.md +98 -0
- package/templates/skills/domain-gamedev/SKILL.md +56 -6
- package/templates/skills/domain-pixi/SKILL.md +256 -7
- package/templates/skills/domain-reid/SKILL.md +39 -5
- package/templates/skills/domain-yolo/SKILL.md +18 -7
- package/templates/skills/ide-zed/SKILL.md +23 -0
- package/templates/skills/lang-as3/SKILL.md +7 -5
- package/templates/skills/lang-haxe/SKILL.md +538 -19
- package/templates/skills/lang-python/SKILL.md +6 -2
- package/templates/skills/math/SKILL.md +64 -2
- package/templates/skills/mcp-setup/SKILL.md +14 -2
- package/templates/skills/preferences/SKILL.md +10 -10
- package/templates/skills/skill-manager/SKILL.md +264 -0
- package/templates/skills/target-openfl-native/SKILL.md +52 -17
- package/templates/skills/target-openfl-native/references/build-and-versions.md +7 -4
- package/templates/skills/task-delegation/SKILL.md +76 -4
- package/templates/skills/web-reading/SKILL.md +33 -25
- package/templates/skills/workflow-authoring/SKILL.md +47 -12
- package/templates/subagent.yaml +29 -8
- package/templates/testing.yaml +64 -10
- package/templates/web-research.yaml +23 -13
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cleanup
|
|
3
|
+
description: Clean .claude directory data
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## .claude directory cleanup
|
|
7
|
+
|
|
8
|
+
Run steps in order. **Ask user confirmation before each step.**
|
|
9
|
+
|
|
10
|
+
### Step 1: Temp data (always safe)
|
|
11
|
+
|
|
12
|
+
Delete contents of (keep directories themselves):
|
|
13
|
+
- `~/.claude/debug/` — session debug logs
|
|
14
|
+
- `~/.claude/shell-snapshots/` — zsh state snapshots
|
|
15
|
+
- `~/.claude/todos/` — task lists from past sessions
|
|
16
|
+
- `~/.claude/plans/` — plan files from past sessions
|
|
17
|
+
|
|
18
|
+
Show size before/after.
|
|
19
|
+
|
|
20
|
+
### Step 2: Project session logs
|
|
21
|
+
|
|
22
|
+
For each project in `~/.claude/projects/`:
|
|
23
|
+
- **DELETE**: `*.jsonl` files (conversation logs), `UUID/` directories (session caches), `agent-*.jsonl` (subagent logs)
|
|
24
|
+
- **KEEP**: `memory/` directory, `CLAUDE.md` file
|
|
25
|
+
|
|
26
|
+
⚠️ This breaks `/resume` for old sessions. Warn user.
|
|
27
|
+
|
|
28
|
+
Show size before/after.
|
|
29
|
+
|
|
30
|
+
### Step 3: Empty project directories
|
|
31
|
+
|
|
32
|
+
After step 2, list projects that have no remaining files (no memory/, no CLAUDE.md).
|
|
33
|
+
Offer to remove them — they recreate automatically when Claude Code runs in that project.
|
|
34
|
+
|
|
35
|
+
### Step 4: Memory audit
|
|
36
|
+
|
|
37
|
+
For each remaining `memory/MEMORY.md`:
|
|
38
|
+
1. Read its contents
|
|
39
|
+
2. Check each entry against existing skills — is it already covered?
|
|
40
|
+
3. Check if Claude already knows this without any skill
|
|
41
|
+
4. Present table to user:
|
|
42
|
+
|
|
43
|
+
| Entry | Duplicates? | Verdict |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| ... | skill X / common knowledge / project-specific | delete / keep |
|
|
46
|
+
|
|
47
|
+
Only modify after user approval.
|
|
48
|
+
|
|
49
|
+
### Other cleanable files
|
|
50
|
+
|
|
51
|
+
Also check and report sizes of:
|
|
52
|
+
- `~/.claude/history.jsonl` — conversation history (can be large)
|
|
53
|
+
- `~/.claude/file-history/` — file change history
|
|
54
|
+
- `~/.claude/paste-cache/` — clipboard cache
|
|
55
|
+
- `~/.claude/tasks/` — task data
|
|
56
|
+
|
|
57
|
+
These are less critical. Mention sizes, let user decide.
|
|
@@ -21,7 +21,7 @@ Load coding skills for files/context in this task.
|
|
|
21
21
|
|
|
22
22
|
3. By domain (detect from imports, paths, or task description):
|
|
23
23
|
- YOLO / object detection → `Skill("domain-yolo")`
|
|
24
|
-
- Pixi.js
|
|
24
|
+
- Pixi.js (imports from `pixi.js`, PIXI globals) → `Skill("domain-pixi")`
|
|
25
25
|
- Person re-identification (ReID) → `Skill("domain-reid")`
|
|
26
26
|
- Game dev (physics, precision, camera, sprites) → `Skill("domain-gamedev")`
|
|
27
27
|
- OpenFL / hxcpp native target → `Skill("target-openfl-native")`
|
|
@@ -35,6 +35,7 @@ Load coding skills for files/context in this task.
|
|
|
35
35
|
- Claude Code settings.json / hooks / permissions / plugin config → `Skill("claude-code-config")`
|
|
36
36
|
- Workflow YAML in templates/ or .claude/workflows/ → `Skill("workflow-authoring")`
|
|
37
37
|
- Fetching web docs / external content → `Skill("web-reading")`
|
|
38
|
+
- Verifying a web/canvas app via Playwright or a browser MCP (screenshots, captures, perf) → `Skill("browser-verify")`
|
|
38
39
|
|
|
39
40
|
5. If 3+ independent files or parallel work → `Skill("task-delegation")`
|
|
40
41
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: debug-bridge-scaffold
|
|
3
|
+
description: Meta-skill for creating debug bridges for new technologies
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Debug Bridge Scaffold
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
When you need runtime inspection of an application built with a technology that has no existing debug bridge.
|
|
11
|
+
|
|
12
|
+
## Step 1: Check Existing Solutions First
|
|
13
|
+
|
|
14
|
+
Before building a custom bridge, check if a standard tool already works:
|
|
15
|
+
|
|
16
|
+
| Technology | Existing Solution |
|
|
17
|
+
|-----------|-------------------|
|
|
18
|
+
| Web/Electron/browser apps | Playwright MCP, browser DevTools |
|
|
19
|
+
| Native apps with standard UI toolkit | Accessibility API MCP |
|
|
20
|
+
| Any app with visible UI | Computer Use (screenshot + click) |
|
|
21
|
+
| OpenFL/hxcpp | `debug-bridge-openfl` skill (already exists) |
|
|
22
|
+
|
|
23
|
+
**If an existing solution covers your needs — use it. Don't build a bridge.**
|
|
24
|
+
|
|
25
|
+
## Step 2: Build a Custom Bridge
|
|
26
|
+
|
|
27
|
+
If no existing tool works, create an HTTP bridge following this pattern:
|
|
28
|
+
|
|
29
|
+
### Architecture
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
[Background Thread] HTTP Server (127.0.0.1:PORT)
|
|
33
|
+
↓ accept()
|
|
34
|
+
[Worker Thread] Parse request → dispatch to main thread
|
|
35
|
+
↓ runOnMainThread(fn)
|
|
36
|
+
[Main Thread] Execute handler → return result
|
|
37
|
+
↑ result via Lock/Mutex
|
|
38
|
+
[Worker Thread] Send HTTP response
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Required Endpoints
|
|
42
|
+
|
|
43
|
+
| Endpoint | Method | Purpose |
|
|
44
|
+
|----------|--------|---------|
|
|
45
|
+
| `/ping` | GET | Health check |
|
|
46
|
+
| `/screenshot` | GET | Capture visual state → save to /tmp → return path |
|
|
47
|
+
| `/shutdown` | POST | Graceful exit |
|
|
48
|
+
|
|
49
|
+
### Framework-Specific Endpoints
|
|
50
|
+
|
|
51
|
+
Add endpoints relevant to the technology:
|
|
52
|
+
- **UI frameworks**: display tree, component inspection, event simulation
|
|
53
|
+
- **Game engines**: scene graph, entity list, physics state
|
|
54
|
+
- **Servers**: request log, connection pool, cache state
|
|
55
|
+
|
|
56
|
+
### Implementation Checklist
|
|
57
|
+
|
|
58
|
+
1. HTTP server on background thread (127.0.0.1 only — no external access)
|
|
59
|
+
2. Main-thread dispatch for all UI/rendering operations
|
|
60
|
+
3. JSON envelope: `{"ok": true, "data": ...}` / `{"ok": false, "error": "..."}`
|
|
61
|
+
4. Conditional compilation (only in debug builds)
|
|
62
|
+
5. Graceful shutdown (close socket, flush stdout, exit)
|
|
63
|
+
|
|
64
|
+
## Step 3: Create a Tech-Specific Skill
|
|
65
|
+
|
|
66
|
+
After building the bridge, create `~/.claude/skills/debug-bridge-<tech>/SKILL.md` with:
|
|
67
|
+
|
|
68
|
+
1. Build command with the debug flag
|
|
69
|
+
2. Binary path pattern
|
|
70
|
+
3. All endpoints with parameters and example responses
|
|
71
|
+
4. Technology-specific gotchas (threading, rendering pipeline, etc.)
|
|
72
|
+
|
|
73
|
+
Register it in `coding-skill-selector` under the appropriate domain.
|
|
74
|
+
|
|
75
|
+
## Step 4: Create an Interactive Debugger Agent
|
|
76
|
+
|
|
77
|
+
Create `~/.claude/agents/interactive-<tech>-debugger.md` with pre-loaded skills:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
---
|
|
81
|
+
name: interactive-<tech>-debugger
|
|
82
|
+
description: <Tech> debug agent with visual debug bridge — ...
|
|
83
|
+
tools: Read, Glob, Grep, Bash, Skill
|
|
84
|
+
model: sonnet
|
|
85
|
+
skills:
|
|
86
|
+
- debugging
|
|
87
|
+
- debug-bridge
|
|
88
|
+
- debug-bridge-<tech>
|
|
89
|
+
- <app-specific-interactive-debug-skill if exists>
|
|
90
|
+
---
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Body: describe the debug loop for the agent — PID tracking, separate build and run steps, timeout rules.
|
|
94
|
+
|
|
95
|
+
Then update:
|
|
96
|
+
1. `task-delegation` — add agent to Available Agents table and routing rules
|
|
97
|
+
2. `testing` — reference the new agent for integration verification
|
|
98
|
+
3. `debugging` — reference the new agent in Step 4 (Debug Bridge)
|
|
@@ -12,19 +12,21 @@ See also: math skill for overflow boundaries (factorial, Fibonacci, float absorp
|
|
|
12
12
|
| Distance from origin | ULP (precision) | Effect |
|
|
13
13
|
|---------------------|-----------------|--------|
|
|
14
14
|
| 100km | 0.78cm | Fine for most games |
|
|
15
|
-
| 524km (≈2^19m) | 3.1cm → 6.25cm | Unity
|
|
15
|
+
| 524km (≈2^19m) | 3.1cm → 6.25cm | Unity world edge zone |
|
|
16
16
|
| 2,097km (2^21m) | 25cm | 5m/s @ 60fps movement ABSORBED — character stops moving |
|
|
17
17
|
| 8,389km (≈2^23m) | 1m | Positions snap to meter grid |
|
|
18
18
|
|
|
19
19
|
**Absorption formula**: movement absorbed when `velocity * dt < ULP(position) / 2`.
|
|
20
20
|
|
|
21
|
+
**Unreal gotcha**: Unreal units are CENTIMETERS, not meters — the same ULP boundaries apply per UNIT, so the metric distances above divide by 100 (e.g. 2^21 units ≈ 21km, not 2,097km).
|
|
22
|
+
|
|
21
23
|
## Deterministic Physics
|
|
22
24
|
|
|
23
25
|
**DLL FPU contamination**: Direct3D, printer drivers, sound libraries can silently change FPU control word (precision/rounding mode) without restoring. Assert FPU state after every external call.
|
|
24
26
|
|
|
25
27
|
**Debug vs release breaks replays**: compiler optimization level alone changes float results even with identical source. All replay participants must use identical binary.
|
|
26
28
|
|
|
27
|
-
**FMA cross-platform**:
|
|
29
|
+
**FMA cross-platform**: fused multiply-add (one rounding) vs separate mul+add (two roundings) → different results from identical math. (The classic PowerPC-has-FMA / Intel-doesn't framing is stale — modern x86 has FMA3 since Haswell and ARM has FMA; today the divergence comes from whether the COMPILER contracts `a*b+c` into FMA, which varies by compiler and flags like `-ffp-contract`.) Cross-platform determinism requires controlling instruction selection.
|
|
28
30
|
|
|
29
31
|
**x87 vs SSE**: x87 uses 80-bit extended precision internally → double rounding when storing to 64-bit. SSE uses exact 32/64-bit → more predictable. But SSE FTZ/DAZ modes may differ across platforms.
|
|
30
32
|
|
|
@@ -32,7 +34,7 @@ See also: math skill for overflow boundaries (factorial, Fibonacci, float absorp
|
|
|
32
34
|
|
|
33
35
|
## CCD Threshold (verified)
|
|
34
36
|
|
|
35
|
-
Enable continuous collision detection when: `velocity × dt
|
|
37
|
+
Enable continuous collision detection when: `velocity × dt >= object_size`.
|
|
36
38
|
- 1m object at 60m/s, 60fps: travels 1m/frame → equals object size → CCD needed
|
|
37
39
|
- Below threshold: discrete detection misses contacts (tunneling)
|
|
38
40
|
|
|
@@ -49,7 +51,7 @@ When a state machine waits for an animation-completion event (e.g. xstate `on.AN
|
|
|
49
51
|
|
|
50
52
|
## Parallax Tile Assets: Native Width = Integration Signal
|
|
51
53
|
|
|
52
|
-
When integrating an artist-authored asset (e.g. `
|
|
54
|
+
When integrating an artist-authored asset (e.g. `landmark.png`) into a row of cyclically-stitched parallax tiles (e.g. `tile_1..6.png`), **check native dimensions first**.
|
|
53
55
|
|
|
54
56
|
- If the asset has the **same native width** as the cyclic tiles → it was designed as ONE of the tiles. Insert it into the stitch sequence at a specific anchor position. Don't overlay.
|
|
55
57
|
- If the asset has **different dimensions** → it's a prop sprite meant to sit on top of the tile row (e.g. a character, tree, bush).
|
|
@@ -86,7 +88,7 @@ In most 2D engines (Pixi, Phaser, etc.), `sprite.anchor.set(ax, ay)` serves TWO
|
|
|
86
88
|
|
|
87
89
|
**Use case**: to rotate a sprite around a specific feature of its PNG (e.g. the left rim of a hazard/foot of a character), put the anchor at that feature's normalized texture coords. Then set `sprite.position` to the world location of that feature and `sprite.rotation` — the feature stays locked in place while the rest swings.
|
|
88
90
|
|
|
89
|
-
**Gotcha**: anchor and
|
|
91
|
+
**Gotcha**: in Pixi the Sprite's `anchor` drives BOTH roles — placement point and rotation origin are not independently settable through it (a separate `pivot` property exists on every Container, but changing it shifts where `position` lands too). If you need placement and rotation origin decoupled, wrap the Sprite in a Container and rotate the Container.
|
|
90
92
|
|
|
91
93
|
## Heightmap From PNG Alpha — Pre-sample Once
|
|
92
94
|
|
|
@@ -127,7 +129,7 @@ When the follow formula is `cameraY = targetScreenY − worldY × zoom` (standar
|
|
|
127
129
|
- Ball RISES in world → `worldY` DECREASES → `cameraY` INCREASES.
|
|
128
130
|
- `Math.min(flightTarget, idleCameraY)` reads as "never go ABOVE idle" in human terms, but `cameraY` for a higher ball is NUMERICALLY LARGER than the idle value — so `Math.min` pins the camera to idle and BLOCKS the follow-up. The correct direction is `Math.max` (if clamping against an upper bound on camera Y) or no clamp at all.
|
|
129
131
|
|
|
130
|
-
**Verify before shipping**: plug in two concrete numbers — ball at rest and ball at peak — and compute `cameraY` for each.
|
|
132
|
+
**Verify before shipping**: plug in two concrete numbers — ball at rest and ball at peak — and compute `cameraY` for each. With `cameraY = targetScreenY − worldY × zoom`, the endpoint with the LARGER `worldY` (ball lower in the world) yields the SMALLER `cameraY`. If the clamp picks the wrong side, the camera becomes useless at exactly the moment it needs to work.
|
|
131
133
|
|
|
132
134
|
**Rule**: every time you combine `screenAnchor − worldPos × zoom` with a `Math.min`/`Math.max`, write the two endpoints on paper. Don't trust the human-language reading of the clamp.
|
|
133
135
|
|
|
@@ -160,3 +162,51 @@ Sub-pixel for flat terrain, but visible (1+ px sliver) on noticeably tilted band
|
|
|
160
162
|
| > 40ms | Unacceptable for rhythm/FPS |
|
|
161
163
|
|
|
162
164
|
Humans react to audio faster than visual → audio lag more noticeable than dropped frames.
|
|
165
|
+
|
|
166
|
+
## Crash / Multiplier-Game RTP (Aviator / Bustabit style)
|
|
167
|
+
|
|
168
|
+
**Canonical crash math**: `crashPoint = (1−he)/(1−U)`, `U∈[0,1)`. This gives `P(crashPoint ≥ x) = (1−he)/x` for `x≥1`. EV at any cashout target `x` = `P(survive to x)·x = (1−he)/x · x = 1−he`. RTP = `1−houseEdge`, **strategy-independent** — the only clean way to get that property.
|
|
169
|
+
|
|
170
|
+
**GOTCHA — forced/consolation payouts break RTP and strategy-independence.**
|
|
171
|
+
Any guaranteed payout on a step the round did NOT crash on (a "soft-loss step saves your accumulated winnings" mechanic, forced cashout on miss, consolation bonus) is EV-positive: it pays value the crash-CDF budget never debited. Effect:
|
|
172
|
+
- Global RTP > target.
|
|
173
|
+
- "Ride longer" becomes the dominant strategy → strategy-independence destroyed.
|
|
174
|
+
|
|
175
|
+
Only voluntary cashouts and a correctly-priced cap/jackpot are EV-neutral. "Soft-loss that saves X" is fundamentally incompatible with strategy-independent crash RTP without full game-math re-optimization around the save mechanics.
|
|
176
|
+
|
|
177
|
+
**GOTCHA — flat auto-paid cap inflates AFK strategy EV.**
|
|
178
|
+
A jackpot cap paid regardless of player action gives the never-cash (AFK) strategy `EV = P(cap)·cap`. EV-neutral only if `P(cap) = (1−he)/cap` exactly (the same CDF). Combine with ANY other forced payout and AFK RTP overshoots the target by tens of percent.
|
|
179
|
+
|
|
180
|
+
**GOTCHA — finite step chain caps reachable multiplier.**
|
|
181
|
+
An N-step chain with per-step multiplier `∈[lo,hi]` tops out at `≈hi^(N−1)`. Crash points above that are structurally unreachable → RTP sags BELOW target for high cashout targets and for never-cash. "Exactly `1−he`, globally strategy-independent" is FALSE for a finite chain; it only holds approximately for reachable targets.
|
|
182
|
+
|
|
183
|
+
| N steps | per-step max | reachable ceiling |
|
|
184
|
+
|---------|-------------|------------------|
|
|
185
|
+
| 5 | ×3 | ×81 |
|
|
186
|
+
| 7 | ×2.5 | ×244 |
|
|
187
|
+
| 10 | ×2 | ×512 |
|
|
188
|
+
|
|
189
|
+
**GOTCHA — PRNG stream independence for crashPoint.**
|
|
190
|
+
`crashPoint` must be statistically independent of the per-step multiplier draws. Drawing it as the first output then the curve from subsequent draws of the same stream is fine (good PRNG outputs are uncorrelated). A separate stream keyed by a near-identical seed through a weak hash (e.g. `seed` vs `seed+":crash"` via FNV-1a → mulberry32) can silently correlate and inflate or deflate RTP. Verify empirically with a large Monte Carlo sweep — don't assume independence from the construction.
|
|
191
|
+
|
|
192
|
+
**GOTCHA — conditional sub-population EV slices carry structural bias.**
|
|
193
|
+
Filtering a Monte Carlo run to "rounds containing event X" and asserting the per-target RTP corridor of the slice ≈ global RTP is wrong whenever X correlates with round length or terminal outcome. Two independent causes:
|
|
194
|
+
(a) *Chain-truncating event*: bonus steps that mostly emit at the crash-branch terminal — the round ends there — so high targets are structurally unreachable. Per-target RTPs of the slice decline monotonically with target; no tolerance widening fixes it.
|
|
195
|
+
(b) *Compensation/dampening gate*: forced-miss rounds replace the event step with a miss variant and never enter the filtered slice — rounds that do enter carry inherited selection bias → the slice mean lands well below the global RTP.
|
|
196
|
+
Pin only the upper bound (no target > 1.10–1.15) to catch +EV regressions, plus a wide mean band (±0.10). Drop per-target corridor, spread, and ride-RTP assertions on any slice with structural correlation between the filter condition and the round's terminal outcome.
|
|
197
|
+
|
|
198
|
+
**PROCESS GOTCHA — Monte Carlo BEFORE porting or regenerating fixtures.**
|
|
199
|
+
Validate the EV model with a sweep across multiple fixed cashout targets before porting to a second language or regenerating golden fixtures. An analytically "correct" model can hide EV-design bugs (forced payouts, chain ceiling, PRNG correlation) that only a per-target simulation exposes. Porting first = double rework when the math turns out wrong.
|
|
200
|
+
|
|
201
|
+
**GOTCHA — economic model must not silently override a player-visible physics event.**
|
|
202
|
+
When a deterministic outcome layer (pre-drawn crash point / book-based result) is draped over a physics renderer, scope any economic reclassification to outcomes the player CANNOT SEE. A physical impact the player watches happen (the ball rams an obstacle and bounces) MUST terminate consistently with what was shown — overriding it back to "safe, continue" so the book stays tidy produces a contradiction caught in the first playtest (observed: the ball visibly hit an obstacle yet the multiplier kept climbing). Rule: renderer-derived classification wins for any event the player can see; the economic model may only override outcomes that are visually indistinguishable.
|
|
203
|
+
|
|
204
|
+
## Virtual-Scroll / Treadmill Takeover — Match Speed at the Handoff Seam
|
|
205
|
+
|
|
206
|
+
When forward motion transitions from **real translation** (object moves in world-X, camera follows) to a **virtual scroll / treadmill** (object parked at a fixed screen X, background streamed past it to fake motion), the streamed background speed must MATCH the object's prior real forward speed at the seam, then ease down. A speed discontinuity — especially a collapse — reads as the object STOPPING, not as continued motion.
|
|
207
|
+
|
|
208
|
+
**Concrete case**: a flying ball soared at ~1700 px/s (real X travel, camera following), then a "cruise" phase parked the ball and streamed the sky at a constant 280 px/s — a ~6× speed collapse at the seam. Players read it as "the ball stopped." Fix: start the treadmill at the soar's exit speed (`≈ (end.x − start.x) / duration`, clamped to a sane max), then smoothstep-decelerate to the idle drift speed (280 px/s). The background keeps moving at the prior rate and gently settles — reads as continuous forward flight arriving into a hover.
|
|
209
|
+
|
|
210
|
+
**Measurement gotcha**: if the engine time-scales animation (e.g. `gsap.globalTimeline.timeScale(speed)` for a `?speed` param), the sampled background px/s is `game-speed × timeScale`. Multiply back by `1/timeScale` before comparing to your intended game-time speed, or a correct value reads as "too slow."
|
|
211
|
+
|
|
212
|
+
**Secondary motion gotcha**: a vertical overlay (e.g. wind weave) at large amplitude (±70 px) on a now-slow background DOMINATES perception — reads as "bobbing in place." Keep secondary motion small relative to apparent forward speed (±70 → ±26 px fixed it).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: domain-pixi
|
|
3
|
-
description: Pixi.js v8
|
|
3
|
+
description: Pixi.js v8 gotchas — graphics, masking, text, shaders, Spine, capture
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Pixi.js v8 Gotchas
|
|
@@ -14,7 +14,7 @@ A polygon with ~625 vertices renders fine as a sprite but produces NO clipping a
|
|
|
14
14
|
|
|
15
15
|
### 2. `setMask({ mask, inverse: true })` does not work for Graphics masks
|
|
16
16
|
|
|
17
|
-
The `StencilMask` object is created (visible on `_maskEffect`) but inverse produces no visible change. Non-inverse normal mask works.
|
|
17
|
+
The `StencilMask` object is created (visible on `_maskEffect`) but inverse produces no visible change. Non-inverse normal mask works. Version qualifier: the `inverse` option only exists since v8.5.0 (earlier v8 ignores it entirely); this failure was observed on v8.6.x — retest on your version before building a workaround.
|
|
18
18
|
|
|
19
19
|
- Wrong: `container.setMask({ mask: g, inverse: true })` to hide inside-shape content
|
|
20
20
|
- Right: build "world minus holes" via keyhole-bridge manually
|
|
@@ -29,12 +29,12 @@ The `StencilMask` object is created (visible on `_maskEffect`) but inverse produ
|
|
|
29
29
|
|
|
30
30
|
Outer boundary CW, hole CCW (Y-down screen coords). For a rhombus {top, left, bottom, right} as a hole inside a CW outer rect:
|
|
31
31
|
|
|
32
|
-
- Wrong
|
|
32
|
+
- Wrong order: top → left → right → bottom — a self-intersecting bowtie, not a CCW circuit; fills instead of holing
|
|
33
33
|
- Right CCW order: top → left → bottom → right (strict CCW around centroid)
|
|
34
34
|
|
|
35
35
|
### 5. Keyhole-bridge: multiple holes in one polygon
|
|
36
36
|
|
|
37
|
-
`FillStyle` has no `fillRule` field — `evenodd` is not available.
|
|
37
|
+
`FillStyle` has no `fillRule` field — `evenodd` is not available. v8 does offer `Graphics.cut()` (draw the hole path after the fill, then `.cut()`), but caveat: it attaches the hole only to the LAST drawn shape (its fill+stroke instruction pair), and a hole that is not completely inside that shape "will fail to cut correctly" (per its own doc). For multiple or edge-touching holes use a zero-width bridge instead:
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
40
|
const pts: number[] = [0, 0];
|
|
@@ -68,7 +68,7 @@ Adding a Graphics mask as a child of the container it masks makes it inherit the
|
|
|
68
68
|
|
|
69
69
|
### 8. `.fill()` has no `fillRule` option
|
|
70
70
|
|
|
71
|
-
`FillStyle` fields: `color`, `alpha`, `texture`, `matrix`, `fill`, `
|
|
71
|
+
`FillStyle` fields: `color`, `alpha`, `texture`, `matrix`, `fill`, `textureSpace` — no `fillRule`. TypeScript will error on `.fill({ fillRule: 'evenodd' })`. (`textureSpace: 'local' | 'global'`, default `'local'`, controls whether texture coords are relative to each shape's bounds or to world space.)
|
|
72
72
|
|
|
73
73
|
- Right: use winding-based holes (#4, #5)
|
|
74
74
|
|
|
@@ -108,7 +108,7 @@ When a scene has multiple nested scales (e.g. `sceneRoot.scale = camera_fit`, `l
|
|
|
108
108
|
|
|
109
109
|
Pixi v8's federated event system (`eventMode='static'` + `pointertap` etc.) only governs dispatch *within* the Pixi scene graph. The original DOM `pointerevent` continues bubbling to anything attached via `window.addEventListener('pointerdown', ...)` or `canvas.addEventListener(...)` regardless of which Pixi child handled it.
|
|
110
110
|
|
|
111
|
-
Failure mode: a HUD/UI container is added to `app.stage` (sibling of the world `sceneRoot`) with interactive Graphics for buttons. Clicking a button fires the Pixi `pointertap` AND the global `window` pointerdown listener that was meant to handle "tap on world to
|
|
111
|
+
Failure mode: a HUD/UI container is added to `app.stage` (sibling of the world `sceneRoot`) with interactive Graphics for buttons. Clicking a button fires the Pixi `pointertap` AND the global `window` pointerdown listener that was meant to handle "tap on world to trigger the primary action" — every panel click double-fires the world action.
|
|
112
112
|
|
|
113
113
|
- Wrong: rely on `eventMode='static'` to "consume" the event for window listeners.
|
|
114
114
|
- Wrong: gate via `if (ev.target !== app.canvas) return` — the canvas IS the target for clicks anywhere over its rect, including over panel children.
|
|
@@ -135,7 +135,7 @@ When two `roundRect` calls stack to fake a "raised button" effect (e.g. tall pil
|
|
|
135
135
|
|
|
136
136
|
### 15. Firefox clips rightmost glyph of Pixi `Text` — set `TextStyle.padding`
|
|
137
137
|
|
|
138
|
-
Pixi v8 sizes the offscreen text texture using `measureText`. Firefox's `measureText` reports tighter advance widths than `fillText` actually paints, especially for `bold`/`800`/`900` weights, so the rightmost 1–2 px of the rightmost glyph fall outside the texture and get clipped. Chrome/Safari measure more generously and don't show the bug. User reports it as "in Chrome works, in Firefox last letter is cut" — affects every label simultaneously (BALANCE → BALANC, $100.00 → $100.0,
|
|
138
|
+
Pixi v8 sizes the offscreen text texture using `measureText`. Firefox's `measureText` reports tighter advance widths than `fillText` actually paints, especially for `bold`/`800`/`900` weights, so the rightmost 1–2 px of the rightmost glyph fall outside the texture and get clipped. Chrome/Safari measure more generously and don't show the bug. User reports it as "in Chrome works, in Firefox last letter is cut" — affects every label simultaneously (BALANCE → BALANC, $100.00 → $100.0, TOTAL WIN → TOTAL WI).
|
|
139
139
|
|
|
140
140
|
- Wrong (clipped in FF):
|
|
141
141
|
```ts
|
|
@@ -161,3 +161,252 @@ Even with `padding` (gotcha #15) keeping FF from clipping, `anchor: 0.5 + sprite
|
|
|
161
161
|
- GOTCHA: only enable `trim` for centered-anchor texts. With `anchor: 0` (top-left labels), `trim` crops the natural ascender/descender whitespace at the top of the canvas, so the visible ink sits HIGHER than `sprite.y` than the untrimmed equivalent — stacked labels with hardcoded vertical offsets will overlap. Either parametrize the style helper (`labelStyle(size, color, weight, trim = false)`) and opt in only at centered call sites, or split into two separate styles.
|
|
162
162
|
- Verification recipe: clip a 1px-tall strip across the rendered text band, sample bg color from outside the glyphs, find first/last column with significantly-different pixels (Manhattan delta > 100). Compare visible-ink center to bg center. Reading rendered canvas pixels via `python -c "from PIL import Image; ..."` works when Pixi's `preserveDrawingBuffer=false` makes JS-side `drawImage` of the WebGL canvas come back empty.
|
|
163
163
|
|
|
164
|
+
### 17. Letterboxed canvas: use `app.canvas.clientWidth/Height`, not `container.clientWidth/Height`
|
|
165
|
+
|
|
166
|
+
`app.renderer.resize(W, H)` sets the canvas CSS size to `W×H` (with `autoDensity: true`; without it the canvas has no CSS size of its own). When the game letterboxes inside a wider container (landscape window), `container.clientWidth > app.canvas.clientWidth`. Overlays laid out using the container dims extend into unrendered space; centering is computed against a wider rect than the canvas occupies.
|
|
167
|
+
|
|
168
|
+
- Wrong: `bg.rect(0, 0, container.clientWidth, container.clientHeight).fill(color)` — bg overshoots the rendered region; "centered" text drifts toward one side.
|
|
169
|
+
- Right: `bg.rect(0, 0, app.canvas.clientWidth, app.canvas.clientHeight).fill(color)` — matches the rendered region exactly.
|
|
170
|
+
- Symptom: overlay (preloader, modal, HUD) appears off-center on landscape; looks correct in portrait where container and canvas widths match.
|
|
171
|
+
- Note: `app.screen.width/height` gives the logical (renderer) resolution before CSS scaling — use `clientWidth/clientHeight` for layout in CSS pixels.
|
|
172
|
+
|
|
173
|
+
### 18. `TilingSprite` wrap-samples the whole `TextureSource` — never feed it an atlas sub-frame
|
|
174
|
+
|
|
175
|
+
Pixi v8 `TilingSprite` repeats the entire backing `TextureSource`, not the `frame` rect of the `Texture` you pass. If the texture is a sub-region of a shared atlas page, every tile wrap boundary bleeds the adjacent atlas content (alpha-bleed rings, packed neighbours), producing periodic seam artifacts across the tiled surface.
|
|
176
|
+
|
|
177
|
+
- Wrong: pack a gradient strip into a shared atlas, then tile it:
|
|
178
|
+
```ts
|
|
179
|
+
new TilingSprite({ texture: atlas.textures['sky_gradient'], width, height });
|
|
180
|
+
// → seam every `tileScale * atlas.textures['sky_gradient'].height` px
|
|
181
|
+
```
|
|
182
|
+
- Right option 1: keep tiled textures as standalone `TextureSource`s (own PNG, not atlas-packed).
|
|
183
|
+
- Right option 2: if the texture must stay in the atlas and is horizontally uniform (e.g. a 1-px-wide gradient strip), use a clamped `Sprite` instead of tiling:
|
|
184
|
+
```ts
|
|
185
|
+
const src = atlasTex.source;
|
|
186
|
+
const { x: fx, y: fy, width: fw, height: fh } = atlasTex.textures['sky_gradient'].frame;
|
|
187
|
+
const cropped = new Texture({ source: src, frame: new Rectangle(fx, fy + cropTop, fw, fh - cropTop) });
|
|
188
|
+
const sprite = new Sprite(cropped);
|
|
189
|
+
sprite.width = sceneWidth;
|
|
190
|
+
sprite.height = fh - cropTop;
|
|
191
|
+
```
|
|
192
|
+
A `Sprite` uses `CLAMP_TO_EDGE` semantics within its frame — no wrap, no seam. Stretching a uniform-column strip horizontally is visually lossless.
|
|
193
|
+
- This is the same underlying constraint as the classic WebGL "repeat requires pow2 or whole texture" rule, restated for v8 atlas usage.
|
|
194
|
+
|
|
195
|
+
### 19. Custom uniforms on a Mesh shader require UBO (`ubo: true` + std140 block) — plain `uniform float foo;` with `UniformGroup` resource silently never syncs to GPU
|
|
196
|
+
|
|
197
|
+
Adding a custom uniform to a `Mesh` shader by declaring `uniform float uTime;` at top level (outside any block) and passing `new UniformGroup({ uTime: { value: 0, type: 'f32' } })` as a resource appears correct: the shader compiles, `glProgram._uniformData.uTime` exists (introspected with type `float`), `shader.groups[99].resources[0]` is the UniformGroup, and the generated sync function emits `gl.uniform1f(ud["uTime"].location, v)`. JS-side mutations to `_waveUniforms.uniforms.uTime` advance correctly each frame. But the GPU never sees the value change — the shader behaves as if `uTime` is permanently 0. The fallback bind-group path (group index 99 for resources without a `gpuProgram` layout) generates a sync function that doesn't actually push plain uniforms.
|
|
198
|
+
|
|
199
|
+
The reliable path is std140 UBO:
|
|
200
|
+
|
|
201
|
+
- Wrong (compiles, draws, but uniform value never updates):
|
|
202
|
+
```glsl
|
|
203
|
+
uniform float uTime;
|
|
204
|
+
```
|
|
205
|
+
```ts
|
|
206
|
+
new UniformGroup({ uTime: { value: 0, type: 'f32' } });
|
|
207
|
+
new Shader({ glProgram, resources: { waveUniforms } });
|
|
208
|
+
```
|
|
209
|
+
- Right (WebGL2 only — requires `#version 300 es`):
|
|
210
|
+
```glsl
|
|
211
|
+
layout(std140) uniform waveUniforms {
|
|
212
|
+
float uTime;
|
|
213
|
+
};
|
|
214
|
+
```
|
|
215
|
+
```ts
|
|
216
|
+
new UniformGroup(
|
|
217
|
+
{ uTime: { value: 0, type: 'f32' } },
|
|
218
|
+
{ ubo: true },
|
|
219
|
+
);
|
|
220
|
+
```
|
|
221
|
+
The resource KEY (`waveUniforms`) must match the GLSL block name. The std140 block size is padded to 16 bytes, so the underlying `Float32Array` is length 4 for a single float — write `group.uniforms.uTime = t; group.update();` per frame.
|
|
222
|
+
|
|
223
|
+
Per-frame update flow that works: bump `_dirtyId` via `group.update()` after writing — `updateUniformGroup` then calls `syncUniformGroup` which writes std140 into `buffer.data`, bumps `buffer._updateID`, and `GlBufferSystem.updateBuffer` re-uploads on the next bind.
|
|
224
|
+
|
|
225
|
+
Verification trick: read `(window as any).__group.buffer.data[0]` over 500 ms — if it advances but the rendered shader output stays frozen, the binding chain is broken (likely the plain-uniform fallback). If `buffer.data[0]` advances and pixel output also advances → UBO path is working.
|
|
226
|
+
|
|
227
|
+
### 20. GLSL ES 3.00 fragment shaders need explicit `precision <qual> float;` — `compileHighShaderGl` does NOT inject one
|
|
228
|
+
|
|
229
|
+
When emitting WebGL2 shaders via `compileHighShaderGl({ template: { vertex: vertexGlTemplate, fragment: fragmentGlTemplate }, bits: [...] })` and prepending `#version 300 es`, the fragment shader fails to link if any custom bit references a float-typed varying or uniform. Symptom: console fills with `WebGL: INVALID_OPERATION: useProgram: program not valid` warnings, the mesh draws silently disappear from the framebuffer (no exception thrown). Vertex stage compiles fine (ES 3.00 supplies an implicit `highp` for vertex floats); only fragment needs the explicit precision.
|
|
230
|
+
|
|
231
|
+
- Wrong:
|
|
232
|
+
```ts
|
|
233
|
+
const fragment = `#version 300 es\n${compiled.fragment}`;
|
|
234
|
+
```
|
|
235
|
+
- Right:
|
|
236
|
+
```ts
|
|
237
|
+
const fragment = `#version 300 es\nprecision highp float;\n${compiled.fragment}`;
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The standard `localUniformBitGl` / `globalUniformsBitGl` shaders bundled with Pixi escape this because their fragment never reads a float — they only write `outColor = vColor`. Any custom bit doing `mix()`, `smoothstep()`, or reading a `varying vec2 vUV` triggers the precision-missing link error.
|
|
241
|
+
|
|
242
|
+
### 21. Freezing a Pixi scene for a deterministic screenshot — stop `Ticker.shared`, NOT just the external tween lib
|
|
243
|
+
|
|
244
|
+
Per-frame motion in a Pixi app often comes from TWO independent drivers: Pixi's own `Ticker.shared` (drives `Spine.autoUpdate` skeletons and any `Ticker.shared.add(...)` per-frame loop) AND a separate tween library (gsap, etc.) driving specific objects. Pausing only ONE freezes only what IT drives. Common failure when capturing a screenshot of a transient overlap/alignment: you pause `gsap.globalTimeline` (which froze the gsap-driven object), take the screenshot a few hundred ms later via your screenshot tool, and the ticker-driven objects have moved on — the exact frame you detected is gone, and the screenshot shows empty space where the detector said a sprite was.
|
|
245
|
+
|
|
246
|
+
- Wrong (only the gsap-driven object freezes; ticker-driven objects keep moving):
|
|
247
|
+
```js
|
|
248
|
+
window.__gsap.globalTimeline.pause(); // gsap object stops, but ticker-driven sprites move before the screenshot
|
|
249
|
+
```
|
|
250
|
+
- Right (freeze BOTH drivers at the detected frame, then screenshot):
|
|
251
|
+
```js
|
|
252
|
+
window.__gsap.globalTimeline.pause();
|
|
253
|
+
ticker.stop(); // freezes Spine autoUpdate + every Ticker.shared sim loop
|
|
254
|
+
```
|
|
255
|
+
- Reaching `Ticker.shared` when it isn't exposed on `window`: in **spine-pixi-v8** every `Spine` instance with `autoUpdate` on holds the ticker at `spineInstance._ticker` (defaults to `Ticker.shared`; overridable via `options.ticker`) — walk the scene to any Spine and read `._ticker`. (Also reachable via `app.ticker` if you have the Application; the Pixi `Ticker` class itself is usually minified / not global in a prod bundle.)
|
|
256
|
+
- Restore after the screenshot: `ticker.start()` + `gsap.globalTimeline.resume()`.
|
|
257
|
+
- Detect-and-freeze in the SAME `requestAnimationFrame` callback that finds the frame (call `pause()` + `stop()` synchronously inside it), so no extra ticks elapse before the freeze. A poll-from-outside-then-pause loses frames to the round-trip latency.
|
|
258
|
+
|
|
259
|
+
### 22. Pinpoint an in-clip Spine animation event frame from AttachmentTimeline data — don't eyeball scrubbed screenshots
|
|
260
|
+
|
|
261
|
+
To find the exact time a prop breaks, an attachment appears/disappears, or a face changes mid-clip, scrubbing the clip and eyeballing screenshots is unreliable: a fast mid-motion pose can look like the event already happened. Concrete miss: a "prop break" was eyeballed at `trackTime ~0.2s` but the prop is still intact then; the real break is `0.933s` — a ~0.7s error that shipped a mistimed banner.
|
|
262
|
+
|
|
263
|
+
Read the animation's `AttachmentTimeline`s from skeleton data. The keyframe where a slot's attachment name changes (to the broken/cracked variant, or to/from `null`) is the exact event time:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const anim = spine.skeleton.data.animations.find(a => a.name === 'break_anim');
|
|
267
|
+
for (const tl of anim.timelines) {
|
|
268
|
+
if (tl.attachmentNames && tl.frames) { // AttachmentTimeline
|
|
269
|
+
const slot = spine.skeleton.data.slots[tl.slotIndex].name;
|
|
270
|
+
// tl.frames[i] = time (s), tl.attachmentNames[i] = attachment (or null)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// e.g. intact `prop_whole` → null AND `shard_1..8` pieces appear, all at t=0.933
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Companion: to RENDER a specific frame for a visual check, set `spine.autoUpdate = false`, then:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const e = spine.state.setAnimation(0, clip, false);
|
|
280
|
+
e.trackTime = t;
|
|
281
|
+
spine.update(0);
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Do NOT call `skeleton.updateWorldTransform()` directly in spine-pixi v8 — it requires a `Physics` argument and throws `physics is undefined`. `spine.update(0)` applies state + transforms correctly.
|
|
285
|
+
|
|
286
|
+
### 23. A BlurFilter on a fully-transparent sprite still renders its padded region — detach it, don't rely on alpha 0
|
|
287
|
+
|
|
288
|
+
A `BlurFilter` (or any filter) attached via `sprite.filters = [blur]` forces a filter render pass that composites the filter's padded region **even when the sprite is fully transparent** (`alpha = 0`). The result is a faint soft-edged rectangle lingering on screen at the sprite's position — the blur edge-clamp of the (invisible) content.
|
|
289
|
+
|
|
290
|
+
- Symptom: sprite is faded to `alpha = 0` (dissolve/burn-out tween) but a faint box persists at its position until something else clears the filter.
|
|
291
|
+
- Wrong: tween `sprite.alpha → 0` and leave `sprite.filters = [blur]` attached, assuming alpha 0 hides everything. The empty filter region keeps compositing.
|
|
292
|
+
- Right: detach the filter when content goes invisible — `sprite.filters = []` in the fade tween's `onComplete` (or whenever alpha hits ~0). An alpha-0 sprite with no filter renders nothing; with a filter it renders the region.
|
|
293
|
+
- Note: a plain alpha-0 sprite (no filter) is correctly skipped by Pixi — only the attached filter forces the stray pass. Verified by toggling `sprite.filters.length` 1→0 at alpha 0: rectangle present with the filter, gone without it.
|
|
294
|
+
|
|
295
|
+
### 24. Deferred texture upload — a standalone atlas page used only by a LATER clip uploads SYNCHRONOUSLY on its first render, stalling that frame. Pre-warm at boot.
|
|
296
|
+
|
|
297
|
+
Pixi v8 uploads a `TextureSource` to the GPU lazily, on the FIRST render that binds it (`GlTextureSystem.bind`→`_initSource`→`onSourceUpdate`→`texImage2D`). A boot "warmup render" (`app.render()` once before revealing the scene) only uploads what's VISIBLE in that idle frame. Any texture whose only consumers are clips/states not shown at idle — a separate Spine FX atlas (flame, explosion, bonus), a rarely-used sprite page — is NOT uploaded at boot. It uploads the first time that clip renders: a one-time synchronous `texImage2D` of the WHOLE page on the gameplay frame that first shows it.
|
|
298
|
+
|
|
299
|
+
- Symptom: a "strong lag SOMETIMES" exactly at a transition (a strike, an ability, a scene change) — "sometimes" = only the FIRST occurrence per page-load, because after that the page is GPU-resident. Invisible on a fast desktop GPU (a ~16 MB upload is a few ms), a clear hitch on mobile (far lower texture-upload bandwidth + driver overhead).
|
|
300
|
+
- A shared atlas page is fine: if the idle scene shows ANY region of `atlas.png`, the whole page uploads at boot, so later clips packed into the SAME page (an action pose, a flag) pay nothing. Only pages with NO region visible at idle defer. (So a Spine action clip on the hero's already-visible atlas = free; the flame's SEPARATE atlas = a deferred stall.)
|
|
301
|
+
- Measure it directly — hook the GL context in an addInitScript BEFORE app boot and timestamp every upload:
|
|
302
|
+
```js
|
|
303
|
+
const og = HTMLCanvasElement.prototype.getContext;
|
|
304
|
+
HTMLCanvasElement.prototype.getContext = function (t, ...r) {
|
|
305
|
+
const gl = og.call(this, t, ...r);
|
|
306
|
+
const proto = Object.getPrototypeOf(gl), o = proto.texImage2D;
|
|
307
|
+
proto.texImage2D = function (...a) {
|
|
308
|
+
const w = typeof a[3]==='number'?a[3]:(a[a.length-1]?.width||0);
|
|
309
|
+
const h = typeof a[4]==='number'?a[4]:(a[a.length-1]?.height||0);
|
|
310
|
+
if (w*h) window.__up.push({ t: performance.now(), w, h, bytes: w*h*4 });
|
|
311
|
+
return o.apply(this, a);
|
|
312
|
+
};
|
|
313
|
+
return gl;
|
|
314
|
+
};
|
|
315
|
+
```
|
|
316
|
+
A big single `texImage2D` (e.g. `2018×2044` = 16.5 MB) landing a few ms before the visible transition, present only ONCE in the whole session, is the smoking gun. A page costs `w·h·4` bytes of GPU RAM regardless of the PNG's compressed size (a 2.6 MB PNG → ~16.5 MB upload).
|
|
317
|
+
- Fix: force the upload at boot, behind the preloader, with `renderer.texture.initSource(source)` (idempotent — no-op if already resident). Reach the source of a spine-pixi-v8 atlas via `Assets.get(atlasAlias).pages[i].texture.texture.source` (the `SpineTexture` wrapper holds the Pixi `Texture` at `.texture`, whose `.source` is the `TextureSource`). Call once per standalone FX atlas right after the warmup `app.render()`.
|
|
318
|
+
- This trades a one-time gameplay stall for slightly more load-time work — the correct tradeoff, same intent as the warmup render itself.
|
|
319
|
+
|
|
320
|
+
### 25. 8-bit gradient banding in additive glows → screen-space additive-noise dither overlay (NOT a post-filter)
|
|
321
|
+
|
|
322
|
+
A soft radial glow built from stacked ADDITIVE Spine/sprite slots, scaled up several×, shows concentric "Mach-band" rings on some mobile panels (and milder banding on iPhones) even though the source texture is a smooth gradient. Cause: across the falloff the additive contribution drops <1 LSB per screen pixel, so the 8-bit framebuffer holds one value for ~16 px then steps +1 — a ring at every step. Desktop GPUs/panels dither it away; many mobile panels render the raw 8-bit, so the rings show. Confirm by sampling a radial line of the captured frame: a banded glow holds RGB constant for 16–24 px then steps by exactly 1 (`(70,131,194)`→`(70,132,195)`), and ×6-amplifying `(frame − local_sky)` makes the rings pop.
|
|
323
|
+
|
|
324
|
+
- **The texture is NOT the bug.** Measure the source alpha falloff: if it's a smooth ramp (many levels, no long flat runs), the banding is purely the final 8-bit COMPOSITE. Re-exporting / dithering the source won't help (it's washed out by the upscale + linear filter before the framebuffer quantizes).
|
|
325
|
+
- **A post-process filter on the already-8-bit frame can't reconstruct the gradient, but ≥1-LSB dither STILL breaks the rings** — adding noise then re-quantizing scatters the hard step edge into noise the eye integrates. Sub-LSB dither does nothing (re-rounds to the same value); you need ~1–2 LSB amplitude.
|
|
326
|
+
- **Cheapest robust fix = a screen-space additive `TilingSprite` of noise** over the world, `blendMode:'add'`, tiny `alpha` (~0.012 → contribution `noise(0..255)·alpha` ≈ 0..3 LSB, mean ~1.5). Added to `app.stage` ABOVE the world, BELOW the HUD. Backend-agnostic (works on WebGL AND WebGPU — no custom shader), one cheap quad (vs a full-frame render-target pass for a `Filter`), and it dithers BEFORE the framebuffer re-quantizes. Use `scaleMode:'nearest'`, `tileScale = 1/renderer.resolution` (≈ one noise texel per device pixel), `eventMode:'none'`. Imperceptible at 1× (verify on the real idle scene), bands gone (radial flat-run maxrun drops from ~16–24 px to ~2–3 px).
|
|
327
|
+
- A custom GLSL/WGSL dither `Filter` is the "more correct" symmetric dither but costs: a full-frame render pass, and on a renderer that may pick WebGPU (Pixi v8 default if no `preference` set in `app.init`) you must supply BOTH a glProgram and a gpuProgram. The additive-noise sprite sidesteps all of that.
|
|
328
|
+
- **GOTCHA — generate the noise from a LOCAL seeded PRNG, never `Math.random()`.** Filling a 128×128 RGBA tile is ~49k draws; if the game seeds any sim state (enemy layout, spawn jitter) off the GLOBAL `Math.random` stream, consuming those draws at boot SHIFTS that layout and silently changes gameplay. Use a local `mulberry32` (a fixed seed is ideal — the dither tile only needs to be uncorrelated, not unpredictable). A render-init utility must never consume the sim's RNG.
|
|
329
|
+
|
|
330
|
+
### 26. A Graphics whose draw is ONLY `g.texture(...)` is UNCLICKABLE — `containsPoint` skips texture instructions; set an explicit `hitArea`
|
|
331
|
+
|
|
332
|
+
Pixi v8 `GraphicsContext.containsPoint` iterates instructions and does `if (!instruction.action || !path) continue;` — `texture` instructions carry no `path`, so a Graphics that draws nothing but texture quads never hit-tests true. Symptom: `eventMode: 'static'` + `onpointertap` on a texture-only Graphics (e.g. an atlas-frame button) silently never fires; the same handler pattern works elsewhere because those draws include `fill()` shapes. No error, cursor may even change (cursor comes from the events system pre-hit in some paths) — the tap just doesn't land.
|
|
333
|
+
|
|
334
|
+
- Wrong (renders fine, never clickable):
|
|
335
|
+
```ts
|
|
336
|
+
<Graphics eventMode="static" onpointertap={onTap}
|
|
337
|
+
draw={(g) => g.texture(frame, 0xffffff, -w/2, -h/2, w, h)} />
|
|
338
|
+
```
|
|
339
|
+
- Right — explicit hitArea (local coords of the Graphics):
|
|
340
|
+
```ts
|
|
341
|
+
<Graphics eventMode="static" onpointertap={onTap}
|
|
342
|
+
hitArea={new Circle(0, 0, w / 2 - pad)} // or new Rectangle(...)
|
|
343
|
+
draw={(g) => g.texture(frame, 0xffffff, -w/2, -h/2, w, h)} />
|
|
344
|
+
```
|
|
345
|
+
- Related trap: a draw of only thin glyph bars (a `−`/`+` made of 2 small rects) IS hit-testable but the target is a sliver — give it a generous `hitArea` rect too.
|
|
346
|
+
- Rule of thumb: any interactive Graphics should either contain at least one `fill()` shape covering the intended target, or carry an explicit `hitArea`. `hitArea` also short-circuits per-shape testing — cheaper on complex draws.
|
|
347
|
+
|
|
348
|
+
### 27. `generateTexture` of a TRIMMED Text at resolution≠1 renders shifted when composited into a RenderTexture atlas — bake untrimmed for atlas pages
|
|
349
|
+
|
|
350
|
+
Baking labels via `renderer.generateTexture({ target: text, resolution: 2 })` where the TextStyle has `trim: true` produces a texture whose CONTENT lands offset (≈ the logical ink height upward) when the baked texture is drawn as a Sprite into another RenderTexture (an atlas page): the recorded frame rect is right, the pixels are not — on retina the atlas shows the label's bottom half at the frame position and garbles every drawn label. At resolution 1 the same pipeline renders perfectly, so the bug ONLY appears on DPR-2 devices — desktop verification with a CDP-forced DPR-1 viewport misses it completely.
|
|
351
|
+
|
|
352
|
+
- Wrong (garbled on retina): `style.trim = true` → `generateTexture({target, resolution})` → Sprite → atlas page.
|
|
353
|
+
- Right: bake UNTRIMMED with the style's `padding` captured as a border (`generateTexture({ target, frame: new Rectangle(-pad, -pad, w+2pad, h+2pad), resolution })`); the padding is symmetric, so centering the FULL padded box visually centers the text. Subtract `pad` where the label butts another element.
|
|
354
|
+
- Verify by extracting the atlas page itself (`renderer.extract.canvas(new Texture({source: page.source}))`) at BOTH resolutions — comparing only final screenshots hides whether the page or the frame math is at fault.
|
|
355
|
+
- Related: assigning `texture.source.resolution = N` after `Texture.from(canvas)` to "mark" a hi-res raster double-counts in any packer that multiplies `tex.width * source.resolution` — leave source.resolution at 1 and scale at draw time with explicit dw/dh.
|
|
356
|
+
|
|
357
|
+
### 28. BitmapText silently DROPS local-space FillGradient — DynamicBitmapFont bakes glyphs without textMetrics; layer two solid texts + an alpha-ramp mask instead
|
|
358
|
+
|
|
359
|
+
A `TextStyle` with `fill: new FillGradient({ textureSpace: 'local', ... })` renders correctly on `Text`, but on `BitmapText` (v8 dynamic bitmap font) the glyphs come out SOLID in (approximately) the LAST stop's colour. Cause: `DynamicBitmapFont._setupContext` calls `getCanvasFillStyle(style._fill, context)` with NO `textMetrics` argument, so the local gradient is built over a 1×1 box — every glyph pixel below y=1 samples the final stop. Same for gradient strokes. No warning is emitted.
|
|
360
|
+
|
|
361
|
+
- Wrong (compiles, renders solid): per-frame multiplier label as `<BitmapText>` with a vertical `FillGradient` fill/stroke.
|
|
362
|
+
- Switching to `Text` "fixes" the gradient but re-rasterizes + re-uploads the whole string every frame — the exact cost BitmapText was chosen to avoid for per-frame counters.
|
|
363
|
+
- Right (keeps BitmapText, exact vertical gradients for BOTH fill and stroke): draw the string TWICE in solid colours and alpha-mask the top copy:
|
|
364
|
+
1. Bottom `BitmapText`: fill = gradient END colour, stroke = stroke-gradient END colour (opaque).
|
|
365
|
+
2. Top `BitmapText` (same text/anchor/pos): fill = gradient START colour, stroke = stroke-gradient START colour.
|
|
366
|
+
3. Mask the top copy with a static 1×H canvas texture: alpha 1 above the digit ink, linear 1→0 across the ink band, 0 below. Sprite spans the text area; `container.mask = sprite` (alpha mask) — verified working in v8.18.
|
|
367
|
+
- BOTH layers MUST declare a stroke of the SAME `width` — `DynamicBitmapFont` feeds `stroke.width` into the glyph padding and draw offsets (`extraPadding = stroke.width`, `tx/ty ± width/2`), so a stroke-less layer rasterises its glyphs SHIFTED vs a stroked one and its fill paints over the other layer's stroke edge (shows as "the top stroke is eaten"). For a TRANSLUCENT stroke (e.g. black @0.4) that must not double-darken under the mask: keep the visible stroke in the BOTTOM layer and give the TOP layer the same-width stroke with `alpha: 0` — identical metrics, zero paint.
|
|
368
|
+
- Ink-band placement: Poppins-like fonts ≈ ink top at 0.30·fontSize below the line top, ink height ≈ 0.70·fontSize — verify against a live screenshot (measure stroke/fill row extents) rather than font tables.
|
|
369
|
+
- Cost: +1 glyph-quad draw + one small alpha-mask pass while the label shows — no per-frame canvas rasterization.
|
|
370
|
+
|
|
371
|
+
### 29. pixi-svelte: a late-becoming-true `{#if}` block APPENDS its Pixi node — template order ≠ z-order; background layers must mount UNCONDITIONALLY
|
|
372
|
+
|
|
373
|
+
pixi-svelte components call `parentContext.addToParent(node)` at mount time. Children that mount TOGETHER land in template order, but a `{#if cond}` block whose condition flips true LATER (data arrives, state changes mid-animation) appends its node to the END of the parent's children — ON TOP of everything already mounted, regardless of where the block sits in the template.
|
|
374
|
+
|
|
375
|
+
- Symptom: a "background" sprite/Graphics written ABOVE a text/sprite in the template renders OVER it — but only when its data arrives after mount (e.g. an atlas frame that resolves later, a kind flag that flips a frame into the animation). Screenshots taken when the condition was true from the start look correct, hiding the bug.
|
|
376
|
+
- Wrong (flame draws on top of the text whenever `flameTex` resolves after mount):
|
|
377
|
+
```svelte
|
|
378
|
+
{#if flameTex}
|
|
379
|
+
<Graphics draw={drawFlame} /> <!-- background -->
|
|
380
|
+
{/if}
|
|
381
|
+
<BitmapText text={label} ... />
|
|
382
|
+
```
|
|
383
|
+
- Right — mount the background node UNCONDITIONALLY (same mount tick as its siblings preserves template order) and guard inside the draw callback; pixi-svelte's Graphics `$effect` runs `graphics.clear()` before every draw, so a null texture just draws nothing:
|
|
384
|
+
```svelte
|
|
385
|
+
<Graphics draw={(g) => { if (flameTex) g.texture(flameTex, ...); }} />
|
|
386
|
+
<BitmapText text={label} ... />
|
|
387
|
+
```
|
|
388
|
+
- Late-mounting is fine for nodes that SHOULD be on top (overlays, masked top layers) — append order is exactly what they need.
|
|
389
|
+
- Verify layering by ZOOMING a screenshot at an actual overlap point (glyph stroke crisp over the background = correct); a row-scan "stroke pixels exist somewhere" check passes in BOTH orders and proves nothing about z.
|
|
390
|
+
|
|
391
|
+
## Verification & capture
|
|
392
|
+
|
|
393
|
+
General Playwright/browser-capture traps (stale headless AND headed screenshots,
|
|
394
|
+
persistent MCP browser cache, headless-vs-device GPU perf) live in the
|
|
395
|
+
`browser-verify` skill — load it alongside this one when verifying visually.
|
|
396
|
+
|
|
397
|
+
### 30. Measuring per-frame motion — sample on the Pixi render ticker, NOT your own `requestAnimationFrame`
|
|
398
|
+
|
|
399
|
+
To verify/diagnose a per-frame animation artifact (judder, stutter, "moves every other frame"), read the object's position INSIDE a callback added to the actual render ticker (`spineInstance._ticker.add(cb)` — walk the scene for a `Spine` with `_ticker`, see #21), not a separate `requestAnimationFrame` loop you spin up. Your own rAF runs on a DIFFERENT clock than the game's update/render and ALIASES against it: when the game updates at a different effective rate than your sampler, you get bogus duplicate frames — a perfectly regular `0, X, 0, X` per-frame delta that looks like a real every-other-frame stutter but is partly your sampling beat. (Headless Chrome makes this worse: observed `_ticker.FPS` bounced 113→120→151 across runs, and gsap/Pixi can run at mismatched rates so BOTH the tween-driven and ticker-driven motion appeared to update at ~60 while the ticker reported 120.)
|
|
400
|
+
|
|
401
|
+
- Wrong (aliased — `0,X,0,X` is partly the sampler beat, present even in a smooth build):
|
|
402
|
+
```js
|
|
403
|
+
let prev; const loop = () => { const x = read(); /* delta = prev - x */ prev = x; requestAnimationFrame(loop); };
|
|
404
|
+
```
|
|
405
|
+
- Right (synced to what is actually drawn — true per-render-frame delta):
|
|
406
|
+
```js
|
|
407
|
+
let ticker; walkScene(n => { if (n._ticker && n.skeleton) ticker = n._ticker; });
|
|
408
|
+
const xs = []; const cb = () => xs.push(read()); ticker.add(cb); /* …900ms… */ ticker.remove(cb);
|
|
409
|
+
// smooth build → every delta ≈ mean (e.g. 4.8–6.1 around 5.4); juddery → 0 / 2×mean alternation (zeroFrac ≈ 0.5)
|
|
410
|
+
```
|
|
411
|
+
- Discriminator: compute `zeroFrac` (fraction of frames with ≈0 delta). A render-synced smooth build is `zeroFrac ≈ 0`; a real every-other-frame stutter is `zeroFrac ≈ 0.5`. Your-own-rAF can't tell them apart.
|
|
412
|
+
- Sibling cause to watch for: a fixed-timestep accumulator (`while (acc >= SIM_STEP_MS) step(SIM_STEP_MS)`) on a `>62.5 Hz` display releases a step only every ~N frames → the object visibly moves in quantized bursts. If the camera is static (so the object's own motion is the only thing on screen), this reads as judder. Fix: step with the real (capped) frame `dt` where determinism isn't required.
|