/** * Hollow Horde — a top-down SURVIVORS-LIKE (Vampire Survivors genre) on Incanto. * Move with WASD / arrows; your knight AUTO-ATTACKS the nearest enemy. Goblins and * faster ghosts pour in from every side and grow tougher over time. Cut them down, * vacuum up the XP shards they drop, and on each LEVEL-UP pick one of three * upgrades. Survive 90 seconds to win — let the horde drain your HP and you fall. * * The horde is BUILT-IN gameplay wired in `game.scene.json`: two `Spawner`s clone * goblin/ghost templates, each enemy `Chase`s the player, and a `DamageOnContact` * hitbox drains `Health`. The custom TypeScript is the survivor FEEL the library * leaves open: `Survivor` (move + auto-attack + XP/level-up/upgrades + HUD), * `Director` (ring-spawn around the player + escalation), `EnemyDrop`, `EnemyFace`. */ import { createGame2D, showBootFailure } from 'incanto/2d'; import ghostUrl from 'incanto/assets/characters/ghost.png'; import goblinUrl from 'incanto/assets/characters/goblin.png'; import knightUrl from 'incanto/assets/characters/medieval-knight.png'; import { useEffect, useRef } from 'react'; import { Director, EnemyDrop, EnemyFace, GridFloor, Survivor } from './behaviors'; import sceneJson from './game.scene.json'; export function App() { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; // StrictMode runs this effect twice against the SAME canvas element, and a // second engine on one canvas is two renderers fighting for one context. // The flag lives on the element, so it lasts exactly as long as the thing // it guards — a page-level game like this one never unmounts otherwise. if (!canvas || (canvas as { _incanto?: true })._incanto) return; (canvas as { _incanto?: true })._incanto = true; void (async () => { const MUSIC_URL = ''; const behaviors = { Survivor, Director, EnemyDrop, EnemyFace, GridFloor }; const scene = structuredClone(sceneJson) as typeof sceneJson & { assets: { knight: { url: string }; goblin: { url: string }; ghost: { url: string } }; }; scene.assets.knight.url = knightUrl; scene.assets.goblin.url = goblinUrl; scene.assets.ghost.url = ghostUrl; /** Dev-only surfaces: the ☰ debug overlay, and the editor behind it. */ const DEBUG = import.meta.env.VITE_INCANTO_DEBUG === '1'; const game = await createGame2D({ debug: DEBUG, // The editor's 📚 buttons, served by `incantoLibrary()` in vite.config.ts. // BOTH halves are the opt-in, and this half was missing from every shipped // template: the plugin alone left a vite config whose own comment promised // "the agent8 asset catalog behind the 📚 buttons" and no button anywhere. // Gated on the same flag, because `editor` DEFAULTS to `debug` and an object // here would turn the editor on in a production build. editor: DEBUG && { library: true }, canvas, scene, behaviors, }).catch((e) => { // Without this the player watches the loading overlay sit at 100% forever: // `#loading` is removed on the line below, so anything that rejects here (no // WebGL context, a scene that will not load) leaves the bar up and the reason // in a console nobody opens. showBootFailure(e); throw e; }); // The console handle FIRST, before any wiring of your own can throw. // `game.stats()`, `game.assetErrors()`, `game.frame()` and `game.engine.log` // are the entire in-page diagnostic surface, and a static build has no other. // Assigned after the wiring, one mistake below took all of them with it. (window as unknown as { game: typeof game }).game = game; // And the loading overlay comes down LAST. Everything between here and there is // your code; a throw in it used to leave a level that renders perfectly with no // player, no error UI, and no way to ask the page what happened. try { if (MUSIC_URL) { game.engine.music.play(MUSIC_URL, { loop: true, fadeIn: 1.5 }); game.engine.audio.music = 0.3; } } catch (e) { showBootFailure(e); throw e; } document.querySelector('#loading')?.remove(); })(); }, []); return ; }