feat: catherine wheel accessory (the hedgehog is now a firework) - #44
Open
ShannonHolgate wants to merge 12 commits into
Open
feat: catherine wheel accessory (the hedgehog is now a firework)#44ShannonHolgate wants to merge 12 commits into
ShannonHolgate wants to merge 12 commits into
Conversation
…ressions Review round 2 found the suite was under-verifying the gsap tween (duration, rotation angle, and killTweensOf on destroy were all unchecked) and sampling the radial spark test at the one angle (progress 0.5) where the direction degenerates, leaving the spec's monotonic heading-sweep requirement untested. Test-only fix per coordinator authorisation: abilities.ts is unchanged.
destroy() clears options.accessories and calls the private syncAccessories() directly, bypassing updateOptions() (the only prior caller of accessoryAbilities.sync()). A hedgehog dying mid-burn kept its catherine wheel ability alive until beforeUnload() eventually ran. Also updates the maybeSpawnFireball() comment, now stale since it fans out to accessory abilities too, not just the skin ability.
getAccessoryAbilityFactory() narrowed with `"createAbility" in info`, which throws a TypeError when `info` is undefined. Accessories are restored from unvalidated storage — state.ts JSON.parses localStorage with no schema, and the browser extension reads chrome.storage from untyped JS — so a stale or hand-edited key reaches it. That key used to cost a log line and a missing sprite; via the new accessory abilities it threw straight out of the HedgehogActor constructor and took hedgehog mode down on startup. Reads the entry through a partial view instead, which gets at the optional member without widening the registry or annotating it (both of which would change the public .d.ts).
The sparks ran on their own setInterval, a second clock for one effect, which broke in two ways. HedgeHogMode.destroy() stops the Matter runner and destroys the Pixi app but never calls beforeUnload() on live elements, so a burning wheel is never torn down. The engine drives gsap from Matter's afterUpdate, so a stopped runner means the tween never completes and never clears the interval. emitSpark() then kept firing at 20Hz forever, leaking a body and throwing into a destroyed stage on every tick until the tab was reloaded. setSpeed (the `slow` and `fast` cheats) scales engine.timing.timeScale and gsap.globalTimeline.timeScale. A wall-clock interval is in neither, so `slow` stretched a 3 second burn to about 12 seconds of real time and roughly 240 sparks instead of 60. Emission now comes from the tween's onUpdate: `ease: "none"` makes the angle linear in tween time, so how far the spin has got is how many of the burn's sparks are owed. One clock for the whole firework — sparks stop dead when the engine stops, and stretch with setSpeed like the spin does. killTweensOf() in destroy() is now a complete stop, as the ability owns no timer of its own. Counting sparks owed by progress rather than accumulating elapsed time and subtracting a 1/20 interval is deliberate: the subtraction drifts, emitting 59 rather than 60 over the ~180 updates a 3 second burn gets at 60fps. The burn test now drives the tween frame by frame to hold that line, and asserts the sparks come out steadily rather than all at once.
The sync() inside updateOptions() nine lines above looks like it already covers this one, so the call reads as duplicated and invites deletion — which would quietly restore the bug it fixes, with no test to catch it.
|
@ShannonHolgate is attempting to deploy a commit to the PostHog Team on Vercel. A member of the Team first needs to authorize it. |
ShannonHolgate
marked this pull request as ready for review
August 28, 2026 00:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
the hedgehog can now be a firework.
what this adds
a
catherine-wheelaccessory. wear it, pressf, and he spins through two fullrotations over three seconds while throwing sparks radially, then stops upright,
slightly dizzy, and ready to go again. reusable, not consumed.
it's the first accessory that does anything. the other sixteen remain purely
decorative, which is fine, they have equity.
the actual change: accessories can grant abilities
skins have had
createAbilitysince spiderhog learned to sling. accessoriesnever did, so all behaviour lived on the skin and a hat was a hat.
HedgehogActorAccessoryInfonow takes an optionalcreateAbility, and a newHedgehogAccessoryAbilitiescollaborator on the actor owns whatever the wornaccessories grant. it sits alongside
ai,controlsandinterfaceand followsthe same shape.
the interesting part is that
sync()diffs rather than rebuilds.updateOptions()runs on every option change, including colour, ai toggle anddrag, so a naive rebuild would snuff a lit firework every time someone recoloured
the hog. it also runs on the death path, which bypasses
updateOptions()entirely, so a hog that dies mid-burn tears the ability down with the accessory
instead of spinning through his own death animation. that one has a comment on it
because it looks redundant next to the
updateOptions()call nine lines above,and it isn't.
fire()is wired into the existingmaybeSpawnFireball(), sofalready meant"use whatever you've got". it just used to only ask the skin.
how the spin works
Actor.forceAngleis what keeps hedgehogs upright. the ability tweens it, andActor.update()copies it ontorigidBody.angle, so the physics body genuinelyrotates too. collision response gets a bit loose for three seconds. it reads as
part of the effect, but flagging it plainly: if you'd rather he stayed physically
upright, the alternative is rotating only the accessory sprite and i'm happy to
switch it.
sparks are existing
FlameActorfireballs thrown along whatever angle the wheelhas reached, so no new projectile type. they keep
FlameActor's mask, so theycollide with platforms and ground and cannot touch actors.
one clock, not two
worth calling out because the first version got this wrong.
the obvious implementation is a gsap tween for the spin and a
setIntervalforthe sparks. that's two clocks, and this engine detaches gsap from its own ticker
and drives
updateRootfrom matter'safterUpdate. so asetIntervalversion:HedgeHogMode.destroy(), which doesn't callbeforeUnload()onelements. runner stops, tween freezes,
onCompletenever fires, interval neverclears, and you get 20hz of leaked matter bodies and uncaught
TypeErrors intothe host page until reload. same family as fix: stop flames crashing on the way out (teardown removeChild guard) #33.
setSpeed, so theslowcheat stretches the spin to about twelveseconds while the sparks keep firing on wall time, giving roughly 240 instead
of 60.
so there is no timer. spark count is derived from spin progress inside the
tween's
onUpdate, which meanskillTweensOfis a complete stop and the wholefirework scales with
setSpeedexactly as the spin does. there are nosetInterval,setTimeoutorrequestAnimationFramecalls in the ability.on the spritesheet
texturepacker 7.4 is a paid gui app, so the frame was packed by hand: the atlas
grew 1440 to 1520 and
sprites.jsongained one entry, alphabetically betweencapandchefwhere a repack would put it. the source frame is committed attexturepacker/assets/accessories/catherine-wheel.png, and the.tpsglobs theassetsdirectory rather than listing files, so your next real repack picks it upand absorbs this cleanly.
things worth knowing:
shapePadding 0,borderPadding 0,extrude 0, max 2048x2048, all matchedmeta.smartupdateis stalehedgehog-mode/half?registering it in the "other" group also means
getRandomAccessoryCombocan handone out, so the ai got a
fireworkentry in its weighted action table at the samefrequency as
jumpandwave. without it a randomly-spawned hog would wear aninert prop. it's one table row and trivially droppable if you'd rather it wasn't
autonomous.
how it was tested
29 unit tests, up from 27.
pnpm test,pnpm --dir hedgehog-mode build,oxlint .andoxfmt --check .all clean.the ability's tests mock gsap and
FlameActor, which is the setup where a suitecan look thorough while only proving mocks got called, so they were written to
fail against specific wrong implementations.
duration: 30instead of 3, an endangle 100x too large, and a deleted
killTweensOfwere each applied and confirmedto break the suite.
in the playground:
ffnot verified by hand: an ai hog setting one off unprompted, and the
slow/fastpaths. both are covered by unit tests but i didn't sit and wait for the ai to roll
it.
no unit test on the death-path teardown. exercising
HedgehogActor.destroy()needs pixi and matter scaffolding this suite doesn't have, and the behaviour it
depends on is already covered against the collaborator directly. saying so rather
than implying coverage that isn't there.
two things that are yours, not mine
the pixi deprecation warning.
addChild: Only Containers will be allowed to add children in v8.0.0, thrown fromsyncAccessorieson every accessory. i checkedbecause it appears while testing this: it dates to
07d7e3f, fires for a tophatjust as readily, and this branch never touches that function. not introduced here,
just noting it so it doesn't get blamed on the wheel.
a residual teardown path. because
HedgeHogMode.destroy()doesn't callbeforeUnload()on elements, a tween that's mid-flight at teardown stays parked ingsap's global timeline and a subsequent engine instance replays it. that's a
property of every gsap tween under the per-instance
updateRootdesign rather thananything the wheel introduces, and #33 looks like it already addresses it. flagging
it rather than reaching into
destroy()from a contributor branch.diff shape
612 insertions, 2 deletions. one of the deletions is the
maybeSpawnFireballcomment that said "(hogzilla only)", which this makes untrue. the other is the
atlas height.
Hedgehog.tstakes 9 added lines,items/Accessory.tsandGlobalKeyboardListeners.tsare untouched, and nothing is renamed.