Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 26 additions & 12 deletions apps/code/src/renderer/components/HedgehogMode.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,9 +16,22 @@ export function HedgehogMode() {
const { data: user } = useMeQuery();
const containerRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<HedgehogModeGame | null>(null);
const pileSpawnerRef = useRef<PileSpawner | null>(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;
Expand All @@ -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");
Expand All @@ -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);
}
Expand All @@ -65,15 +88,6 @@ export function HedgehogMode() {
};
}, [hedgehogMode, user?.hedgehog_config, setHedgehogMode]);

useEffect(() => {
return () => {
if (gameRef.current) {
gameRef.current.destroy();
gameRef.current = null;
}
};
}, []);

return (
<div
ref={containerRef}
Expand Down
178 changes: 178 additions & 0 deletions apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type {
GameElement,
HedgeHogMode,
UpdateTicker,
} from "@posthog/hedgehog-mode";
import { logger } from "@utils/logger";
import Matter from "matter-js";
import { Graphics } from "pixi.js";

const log = logger.scope("pile-spawner");

const PROJECTILE_CATEGORY = 0x0004;
const MAX_PILES = 3;
const SPAWN_INTERVAL_MS = 15_000;
const BLOCK_SIZE = 32;
const REST_LIFETIME_MS = 8_000;
const REST_SPEED_THRESHOLD = 0.05;
const PILE_HEIGHT_MIN = 6;
const PILE_HEIGHT_MAX = 10;
const PALETTE = [0xf8c537, 0xe36588, 0x5bc0eb, 0x9bc53d, 0xfde74c, 0xc792ea];

class PileBlock implements GameElement {
readonly rigidBody: Matter.Body;
readonly isInteractive = false;
readonly pileId: number;
private readonly graphics: Graphics;
private readonly game: HedgeHogMode;
private restMs = 0;
private dead = false;

constructor(
game: HedgeHogMode,
pileId: number,
x: number,
y: number,
color: number,
) {
this.game = game;
this.pileId = pileId;

this.rigidBody = Matter.Bodies.rectangle(x, y, BLOCK_SIZE, BLOCK_SIZE, {
density: 0.0015,
friction: 0.5,
frictionAir: 0.01,
restitution: 0.15,
label: "PileBlock",
collisionFilter: {
category: PROJECTILE_CATEGORY,
mask: 0xffff,
},
});
Matter.Composite.add(game.engine.world, this.rigidBody);

this.graphics = new Graphics();
this.graphics.rect(
-BLOCK_SIZE / 2,
-BLOCK_SIZE / 2,
BLOCK_SIZE,
BLOCK_SIZE,
);
this.graphics.fill(color);
this.graphics.position.set(x, y);
this.graphics.zIndex = 1000;
game.app.stage.addChild(this.graphics);
}

update({ deltaMS }: UpdateTicker): void {
if (this.dead) return;
const body = this.rigidBody;
this.graphics.position.set(body.position.x, body.position.y);
this.graphics.rotation = body.angle;

if (
body.position.y > 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<typeof setInterval>;
private readonly blocks = new Set<PileBlock>();
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<number>();
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,
});
}
}
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading