diff --git a/apps/code/package.json b/apps/code/package.json index 7c0f9d430..67f259066 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -60,6 +60,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", + "@types/matter-js": "^0.20.0", "@types/node": "^24.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", @@ -173,10 +174,12 @@ "inversify": "^7.10.6", "is-glob": "^4.0.3", "lucide-react": "^1.7.0", + "matter-js": "^0.20.0", "micromatch": "^4.0.5", "node-addon-api": "^8.5.0", "node-machine-id": "^1.1.12", "node-pty": "1.1.0", + "pixi.js": "^8.16.0", "posthog-js": "^1.283.0", "posthog-node": "^5.24.10", "radix-themes-tw": "0.2.3", diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index cc3d79584..1f50c1d42 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -1,3 +1,4 @@ +import type { PileSpawner } from "@components/hedgehog-mode/PileSpawner"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useMeQuery } from "@hooks/useMeQuery"; import type { @@ -15,9 +16,22 @@ export function HedgehogMode() { const { data: user } = useMeQuery(); const containerRef = useRef(null); const gameRef = useRef(null); + const pileSpawnerRef = useRef(null); useEffect(() => { - if (!hedgehogMode || !containerRef.current || gameRef.current) return; + if (!hedgehogMode) { + if (pileSpawnerRef.current) { + pileSpawnerRef.current.destroy(); + pileSpawnerRef.current = null; + } + if (gameRef.current) { + gameRef.current.destroy(); + gameRef.current = null; + } + return; + } + + if (!containerRef.current || gameRef.current) return; let cancelled = false; const container = containerRef.current; @@ -30,8 +44,11 @@ export function HedgehogMode() { | HedgehogActorOptions | undefined; - import("@posthog/hedgehog-mode") - .then(async ({ HedgeHogMode }) => { + Promise.all([ + import("@posthog/hedgehog-mode"), + import("@components/hedgehog-mode/PileSpawner"), + ]) + .then(async ([{ HedgeHogMode }, { PileSpawner }]) => { if (cancelled) return; log.info("Creating hedgehog game instance"); @@ -48,10 +65,16 @@ export function HedgehogMode() { }); gameRef.current = game; + ( + window as unknown as { __hedgehogGame?: HedgehogModeGame } + ).__hedgehogGame = game; try { await game.render(container); log.info("Game rendered, hedgehogs:", game.getAllHedgehogs().length); + if (gameRef.current === game) { + pileSpawnerRef.current = new PileSpawner(game); + } } catch (err) { log.error("Game render failed", err); } @@ -65,15 +88,6 @@ export function HedgehogMode() { }; }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode]); - useEffect(() => { - return () => { - if (gameRef.current) { - gameRef.current.destroy(); - gameRef.current = null; - } - }; - }, []); - return (
window.innerHeight + 400 || + body.position.x < -400 || + body.position.x > window.innerWidth + 400 + ) { + this.dead = true; + return; + } + + if (body.speed < REST_SPEED_THRESHOLD) { + this.restMs += deltaMS; + if (this.restMs > REST_LIFETIME_MS) { + this.dead = true; + } + } else { + this.restMs = 0; + } + } + + beforeUnload(): void { + this.game.app.stage.removeChild(this.graphics); + this.graphics.destroy(); + } + + get isDead(): boolean { + return this.dead; + } +} + +export class PileSpawner { + private readonly game: HedgeHogMode; + private readonly timer: ReturnType; + private readonly blocks = new Set(); + private nextPileId = 1; + private destroyed = false; + + constructor(game: HedgeHogMode) { + this.game = game; + this.spawnPile(); + this.timer = setInterval(() => this.tick(), SPAWN_INTERVAL_MS); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + clearInterval(this.timer); + for (const block of this.blocks) { + this.game.removeElement(block); + } + this.blocks.clear(); + } + + private tick(): void { + if (this.destroyed) return; + + for (const block of [...this.blocks]) { + if (block.isDead) { + this.game.removeElement(block); + this.blocks.delete(block); + } + } + + const livePileIds = new Set(); + for (const block of this.blocks) { + livePileIds.add(block.pileId); + } + + if (livePileIds.size < MAX_PILES) { + this.spawnPile(); + } + } + + private spawnPile(): void { + if (this.destroyed) return; + + const margin = 80; + const xRange = Math.max(window.innerWidth - margin * 2, 200); + const x = margin + Math.random() * xRange; + const groundTopY = window.innerHeight; + const height = + PILE_HEIGHT_MIN + + Math.floor(Math.random() * (PILE_HEIGHT_MAX - PILE_HEIGHT_MIN + 1)); + const color = PALETTE[Math.floor(Math.random() * PALETTE.length)]; + const pileId = this.nextPileId++; + + for (let i = 0; i < height; i++) { + const block = new PileBlock( + this.game, + pileId, + x + (Math.random() - 0.5) * 4, + groundTopY - BLOCK_SIZE / 2 - i * BLOCK_SIZE, + color, + ); + this.game.elements.push(block); + this.blocks.add(block); + } + log.info("Spawned pile", { + pileId, + x, + height, + groundTopY, + stageChildren: this.game.app.stage.children.length, + worldBodies: this.game.engine.world.bodies.length, + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d41dfe7bc..202f3718f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.1.0) + matter-js: + specifier: ^0.20.0 + version: 0.20.0 micromatch: specifier: ^4.0.5 version: 4.0.8 @@ -307,6 +310,9 @@ importers: node-pty: specifier: 1.1.0 version: 1.1.0(patch_hash=4dfdf785f5ac51a03f5d6032371cebe89036381acd403621f250a896245647c5) + pixi.js: + specifier: ^8.16.0 + version: 8.16.0 posthog-js: specifier: ^1.283.0 version: 1.340.0 @@ -440,6 +446,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/matter-js': + specifier: ^0.20.0 + version: 0.20.2 '@types/node': specifier: ^24.0.0 version: 24.12.0 @@ -5409,6 +5418,9 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/matter-js@0.20.2': + resolution: {integrity: sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -17576,6 +17588,8 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/matter-js@0.20.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3