@mundogamernetwork/mg-city
v0.6.7
Published
Mundo Gamer City 2D engine - framework-free TypeScript + Phaser 3 package powering MGN's interactive onboarding city
Downloads
1,961
Readme
@mundogamernetwork/mg-city
Framework-free TypeScript + Phaser 3 engine for Mundo Gamer City — MGN's interactive 2D onboarding experience. A side-view linear avenue (visual ref: hi-res pixel art, large expressive characters, golden-hour parallax) where MGN platform buildings appear left→right in the order of the selected profile's journey, with sequential gating. The engine renders the city, entities and in-canvas fx only; all real UI (dialogue box, HUD, chapter cards, menus) is the host's DOM overlay, driven through a typed event bus. It never imports Vue/Nuxt/Pinia.
Install
npm install @mundogamernetwork/mg-city phaserphaser (^3.80) is a peer dependency. Only runtime dep: mitt (event bus). Movement is pure 1D walk left/right + interact — no pathfinding library, no jumping.
Public API
import {
createGame,
destroyGame,
createCityBus,
parseContentBundle,
computeCoinTotals,
type ContentBundle,
type CityGameHandle,
type CityBus,
} from '@mundogamernetwork/mg-city'
const bus = createCityBus()
const handle: CityGameHandle = createGame(containerEl, bundleJson, {
bus,
profileSlug: 'indie-developer', // orders the avenue + colors the avatar
completedSlugs: ['community'], // host/server-owned progression
collectedCoinIds: ['community_c1'], // host/server-owned coin state
assets: [{ key: 'sign_jobs', url: '/signs/sign_jobs.png' }], // official pixel plates
})
handle.journey // resolved left→right avenue order
handle.getCoinTotals() // { global, perDistrict } collectible totals
// … later, on route leave:
destroyGame(handle) // or handle.destroy()createGame(container, contentBundle, options?):
- Validates the bundle with
parseContentBundle(throwsBundleParseErrorwith a precise message; passskipValidation: trueif already validated). - Boots Phaser with
Scale.RESIZE(fills the container),AUTOrenderer (WebGL with Canvas fallback; force withrenderer: 'canvas'),pixelArt: trueand pixel-safe camera zoom snapping (never fractional), DPR capped at 2 (maxDpr).
Ownership model (important)
- Progression is HOST-owned. The engine renders gating from
completedSlugs/ui:set-progress; the host decides what "completed" means (exit interior, close chapter card, finish mission…). Completed buildings stay re-enterable — backtracking for missed coins is a feature. - Coin state is HOST-owned.
city:coin-pickedis an intent; the host/server validate and persist. On (re)boot,collectedCoinIdsseeds what never respawns; missed coins respawn on revisit. - Dialogue actions are HOST-resolved. The engine emits
city:dialogue-openand locks input; the host walks the node tree, executesactions (open_link,bookmark_feature,conversion_cta, …) and repliesui:dialogue-closed. - Presence networking is HOST-side. The host joins its presence channel, publishes the throttled
city:player-moved, and feeds peers viaui:presence-update. The engine only renders ghosts.
Event bus contract
The host listens to city:* and sends ui:*. All payloads are typed (src/types/events.ts).
| Engine → host | Payload | When |
|---|---|---|
| city:ready | {version, locale, journey, coinTotals} | Avenue created — echoes the resolved order + collectible totals |
| city:load-progress | {progress: 0..1} | Asset preload |
| city:building-approach | {slug, locked} | Player nears a building door (locked = sequential gating) |
| city:interactable | {kind: 'npc'\|'door'\|'prop'\|'stairs'\|'exit_door'\|null, targetId, districtSlug} | Player entered/left an interaction zone (null = left). Stairs report targetId: 'stairs_up'\|'stairs_down' |
| city:district-enter / city:district-exit | {districtSlug, interior, via?} | Interior entered / exited. via:'door' (ground-floor entry door → back AT the building) or via:'exit' (far-end exit door on the final floor → re-placed just PAST the building; forward flow) |
| city:chapter-open | {districtSlug, chapter} | Door of a district without interior — host shows the chapter card |
| city:dialogue-open | {dialogueId, npcId, districtSlug} | NPC interaction. Engine locks player input until ui:dialogue-closed |
| city:coin-picked | {spawnKey, value, districtSlug, x, y} | Player touched a coin (host owns the real balance / server sync) |
| city:prop-interact | {propId, kind, districtSlug} | Player used an interactive prop (feed composer, key kiosk, vacancy board…). Engine locks player input until ui:dialogue-closed — the host opens a simulation modal |
| city:mission-step | {missionId, districtSlug, step, payload?} | Mission progress intent (server validates) |
| city:easteregg | {eggId, districtSlug} | Easter-egg trigger hit |
| city:player-moved | {x, districtSlug, moving} | Own avenue position, throttled ~1/s (feed your presence channel) |
| city:walk-arrived | {x} | A ui:walk-to command finished (guided tour chaining) |
| Host → engine | Payload | Effect |
|---|---|---|
| ui:dialogue-closed | {dialogueId, outcome?} | Unlocks player input |
| ui:set-progress | {completedSlugs} | Host-owned progression → re-render gating (✓ / next / locked) |
| ui:walk-to | {x} | Guided tour: auto-walk the player to x with the normal walk anim |
| ui:teleport | {districtSlug \| null} | Place player at a district door (or the avenue start) |
| ui:move | {dir: 'left'\|'right'\|null} | Host-DOM mobile arrows (null = release) |
| ui:presence-update | {players: [{id, x, spriteVariant, label?}]} | Full peer snapshot ~1/s; engine diffs by id, interpolates, caps 15 ghosts |
| ui:audio | {music?, sfx?} (boolean or {enabled, volume?}) | Starts/stops the music loop, gates SFX. Both channels muted by default — send the first enable after a user gesture (autoplay policy; engine also guards with Phaser's locked-audio handling) |
| ui:sfx | {key} | Host passthrough to play an engine-loaded SFX for Vue-side moments (dialogue node advance, chapter/simulation complete) |
| ui:input-lock | {locked} | Hard input lock (e.g. while a modal is open) |
| ui:pause / ui:resume | — | Pause/resume the active scene |
ContentBundle contract (v0.2 — avenue model)
Full types in src/types/bundle.ts; committed example in fixtures/mock-bundle.json (10 districts, 3 full interiors, 16 profiles). There are no plots — avenue ordering is fully journey-driven. Shape summary:
{
"meta": { "version": "…", "locale": "en" },
"districts": [{
"slug": "jobs", // unique — duplicates rejected at parse
"exterior": { "spriteKey": "…", "sign": { "text", "color", "iconUrl?", "spriteKey?" } },
"interior": { // or null → door emits city:chapter-open
"template": "office|studio|hall|plaza", // wide multi-floor cutaways (plaza = 2 floors)
"theme": { "accent": "#2E86DE", "secondary?": "…", "floor?": "…", "wall?": "…" },
"npcs": [{ "id", "spriteKey", "slot?": "npc_slot_1..4", "x?", "floor?": "ground|floor_2", "vignette", "dialogueId", "name?", "facing?": "left|right" }],
"props": [{ "id?", "slot?": "screen_slot_1..2", "x?", "media": { "kind": "image|video|embed", "url" },
"featureId?", "floor?", "interactive?": { "kind": "feed_composer|key_kiosk|…" } }], // slot=wall screen, x=floor kiosk
"coins": [ { "key", "x", "y", "value", "floor?" }, // point spawn (room-space coords)
{ "key", "x", "y", "width", "height", "count", "value" } ], // area spawn → keys `${key}#${i}`
"missions": [{ "id", "type", "objective", "coinReward", "title?" }],
"easterEggs": [{ "id", "trigger": { "kind": "x", "x", "radius?" }, "coinReward", "hint?" }]
},
"chapter": { "title", "ctaUrl", "recommendTags": [] }
}],
"profiles": [{ "slug", "label", "spriteVariant": { "body", "outfit", "accessory", "palette" },
"recommendedDistricts": [] }], // ordered journey — these buildings come FIRST
"dialogues": { "dlg-id": { "id", "start", "nodes": { "n1": { "id", "type": "line|choice|action", "speaker?", "text?", "next?", "choices?", "action?" } } } }
}Notes for backend/frontend teams:
- Avenue order = profile's
recommendedDistrictsfirst (in that order), then every other district in bundle order.computeJourneyOrderis exported if the host needs it. - Sequential gating: only the first uncompleted building in the journey is enterable; earlier ones show ✓ and remain enterable; later ones are locked. State comes from the host, never the engine.
- Coin spawn keys are the idempotency contract with
POST /city/sessions/{uuid}/coins. Area spawns expand to${key}#${index}.computeCoinTotalsgives the HUD's "collected/total" denominators per district and globally. - Validation fails loudly at boot: duplicate district slugs, profiles recommending unknown districts, NPCs referencing missing dialogues, dialogues with missing start nodes, malformed signs.
- Vignettes (
idle,idle_typing,buried_in_papers,chasing,recording,studying,interviewing,browsing_feed,posting,presenting,queueing,celebrating) are code-authored in the engine; bundles select them by id.NpcDef.facingflips the sprite so paired vignettes (twointerviewingNPCs) face each other. - Interactive props (
interactive: {kind}) get the same prompt flow as NPCs; confirming emitscity:prop-interactand locks input until the host repliesui:dialogue-closed.propId=PropDef.id, falling back to the slot name orx:<px>. - Interiors are wide multi-floor cutaways (v2): templates define stacked
floors(2-3 screens wide each; camera scrolls horizontally and eases vertically). NPCs/props/coins address a floor by id (floor: 'floor_2', default ground); slot names resolve within that floor; coin coords stay room-space so totals math is unchanged. Floors connect through x-aligned stair zones (walk in + interact;city:interactable {kind:'stairs', targetId:'stairs_up'|'stairs_down'}). The final floor's far-end exit door (kind:'exit_door') emitscity:district-exit {via:'exit'}and the avenue re-places the player just past the building — enter left, traverse, exit right. Parse rejects unknown floor ids (TEMPLATE_FLOOR_IDS). Single-floor templates keep working (floors array with one entry; ground-floor mirrors on the template root). - Signs never render HD logos in-canvas (breaks pixel art). Rendering order:
sign.spriteKey(official pixel-art brand plate PNG, nearest-neighbor at 2×, brand-color glow) →sign.iconUrl(small PNG downscaled to ~18px on the text plate — only for logos that survive it) → text-only pixel plate with the brandcoloras accent. HD logos live in the host's DOM overlay.
Art & assets
All placeholder art is generated programmatically at preload (Phaser Graphics → generateTexture, once, never per-frame): golden-hour parallax sky/clouds/skylines, textured street with lamp glow/benches/trees, varied facades with lit window grids and brand awnings, articulated ~92px characters (head/torso/legs, 2-frame walk cycle, facing flip, idle bob), coin glow, ambient fireflies. See src/art/.
The official pixel-art brand sign plates live in assets/signs/ (sign_<slug>.png, generated by tools/generate_brand_signs.py) and are loaded by the host via the assets option (the playground uses import.meta.glob). Real hi-res pixel-art atlases (facades, characters) drop in the same way — any bundle spriteKey resolved by a loaded texture skips its procedural placeholder.
Produced pixel art (assets/sprites, assets/backgrounds)
The in-house art set (generated by tools/art/build_all.py; contact sheets in assets/preview/) replaces the placeholders when loaded through the same assets option:
char_<profile>.png— 16 sheets, 6 frames of 24x48 (walk0..3contact/pass cycle @8fps,idle,talk; feet baseline y=45). Selected byprofileSlug; presence ghosts pick deterministically among loaded sheets.npc_generic_1..8.png— 4 frames of 24x48 (2-frame idle + walk pair); NPCs without an exactspriteKeytexture pick one deterministically per NPC id.facade_<slug>.png+facade_wall-of-legends.png— varied silhouettes (bottom row = sidewalk); the brandsign_<slug>plate anchors in the facade's 76x32 plate zone; the monument closes the avenue after the last building.sky/clouds/skyline_{far,mid,near}.png— tileable parallax strips (distinct scroll rates).interior_<template>.png— 1200x180 floor strips (floor top y=148), tiled per floor; decorativeprop_*sprites dress each floor deterministically;prop_kiosk/prop_bigscreenskin the interactive props.coin.png(4-frame spin) +sparkle.png+item_*.png.
Everything renders at ART_SCALE (2x, nearest-neighbor). Every consumer keeps its procedural fallback — a missing key (e.g. an admin-added district without art) degrades gracefully to the placeholder pass. Spritesheet asset defs carry {type:'spritesheet', frameWidth, frameHeight} (see AssetDef in src/assets.ts).
Audio
Chiptune assets live in assets/audio/ (generated by tools/generate_audio.py; regen with python3 tools/generate_audio.py, then python3 tools/convert_audio.py to derive the compressed formats via ffmpeg): music_city_loop (20s seamless 8-bar loop) + SFX (sfx_coin, sfx_door, sfx_dialogue, sfx_mission, sfx_step, sfx_secret, sfx_denied). Each key ships three files — .wav (source), .ogg (Vorbis, ~8x smaller than wav, used by Chrome/Firefox/Android), and .m4a (AAC-LC, used by Safari/iOS — Safari cannot reliably decode ogg/vorbis, hence this fallback). The host registers multi-format audio through the assets option using type: 'audio' with either sources: { ogg, m4a } or urls: [...] (instead of a single url); AssetDef/manifestEntryFor in src/assets.ts turn that into the ordered URL list Phaser's load.audio(key, urls) uses, and the sound manager auto-picks whichever format canPlayType reports as supported. A bare single url (or a def with no sources/urls) still works — it's classified as audio by extension, same as before. If ffmpeg isn't on PATH, tools/convert_audio.py prints the exact commands to run later instead of silently skipping.
The AudioSystem (src/audio.ts) keeps both channels muted/stopped by default; the host enables them with ui:audio (first enable after a user gesture). Engine-side SFX: coin pickup, door enter/exit, dialogue/prop-interact open, easter egg (sfx_secret), locked-door attempt (sfx_denied), and footsteps at ~0.28s cadence (subtle volume) while walking. Host-side moments (dialogue node advance, chapter/simulation completion) go through ui:sfx {key}. Default volumes: music 0.5, sfx 0.8 — override via the object form of ui:audio.
Playground
npm install
npm run dev # http://localhost:5199Full side-view avenue from the mock bundle: pick a profile (reorders the buildings), walk with ←→/AD or tap/click, stop at a door (or press E/SPACE/ENTER) to enter. The sidebar is a minimal HOST implementation: HTML dialogue box + chapter card, MGC collected/total HUD, ui:set-progress loop (exiting an interior / closing a chapter card completes it), mobile arrow zones, a presence-peer simulator, a guided-tour "walk to next building" button, and a live bus-event log.
Scripts
| Command | What |
|---|---|
| npm run dev | Vite playground |
| npm run build | Library build → dist/ (ES + CJS + .d.ts) |
| npm run test | Vitest: bundle contract, journey/gating, avenue layout, coins, presence, bus |
| npm run typecheck | tsc --noEmit (strict) |
