Hero Look Contract - xopherdeep/do-it-for-the-xp GitHub Wiki

Hero Look (voxel character) contract, 2026-10-10

Goal: every hero has an Unrailed-style blocky voxel character they dress and customise. It lives on a new My Look screen reached from the profile, animates (idle breathing, wave, walk, victory, hurt, dance), is saved on the profile, and a rendered snapshot of it becomes the hero's avatar everywhere the app shows one.

Shared types (frozen): src/lib/hero-look/types.ts — HeroLook, HeroAnimation, DEFAULT_LOOK, SKIN_TONES, LOOK_COLORS, the *_STYLES lists. Dependency: three 0.186 is installed (import * as THREE from "three"). Keep the bundle lean: import from "three" only (tree-shakes), no examples loaders, no postprocessing.

A. Renderer (owner: model agent) — src/components/organisms/HeroModel/

  • voxel/parts.ts: a part library. A part is a list of boxes { x, y, z, w, h, d, color } in a 16-unit-tall character space (1 unit = 1 voxel, origin at the feet centre, +Y up, +Z toward the camera). Parts: body(skin, outfit, outfitColor, outfitAccent) (torso, arms as separate groups named "armL"/"armR", legs "legL"/"legR"), head(skin, face, eyeColor) (head block + eyes + mouth per FaceStyle as small voxels), hair(style, color) for every HairStyle, hat(style, color) for every HatStyle, held(iconName) (a small generic tool/sword/shield/wand/book shape chosen from the icon name, or null). Colours come from the look; outfits must look distinct from each other (overalls bib, robe is long and hides legs, armor has shoulder plates, hoodie has a hood, dress is a skirt, tracksuit has stripes, pyjamas are striped).
  • voxel/build.ts: buildHero(look: HeroLook): THREE.Group with named child groups: head (includes hair, hat, face), torso, armL, armR, legL, legR, handL, handR. Use ONE merged BufferGeometry per group built from box faces with vertex colours (MeshLambertMaterial({ vertexColors: true })), no per-voxel meshes, no textures. Add a flat dark disc shadow under the feet. disposeHero(group) frees geometries/materials.
  • voxel/animate.ts: animateHero(group, animation: HeroAnimation, t: seconds) sets rotations/positions of the named groups procedurally: idle (breathing scale on torso, slight head bob, arms sway), wave (right arm up waving), walk (legs/arms swing, body bob), victory (hop + both arms up, repeat), hurt (lean back, flash: tint vertex colours is not needed, use a quick shake), dance (hip sway, arms alternate). Pure function of t, no stored state.
  • useHeroScene.ts(canvasRef, look: Ref<HeroLook>, animation: Ref<HeroAnimation>, options?: { interactive?: boolean; background?: 'transparent' }): WebGLRenderer (alpha, antialias off on dpr>1.5, pixel ratio capped at 2), orthographic-ish PerspectiveCamera at a 3/4 front view, hemisphere + directional light with soft shadow OFF (keep it cheap), one rAF loop that pauses when the tab is hidden or the canvas is off-screen (IntersectionObserver), ResizeObserver sizing, drag/touch to turn the hero (y rotation with inertia) when interactive, rebuilds the hero when look changes (dispose the old one), dispose() on unmount. Expose snapshot(size = 256): string that renders one frame at size×size with a transparent background into a data URL (PNG).
  • XpHeroModel.vue: props look: HeroLook, animation?: HeroAnimation = 'idle', interactive?: boolean = true, size?: number (CSS px, default fills parent). Renders the canvas, exposes snapshot() via defineExpose. No Ionic imports.
  • Tests __tests__/voxel.spec.ts: buildHero returns the named groups for every hair/hat/outfit/face combination sample (loop over the style lists), each group has a geometry with vertex colours, disposeHero does not throw; animateHero mutates transforms differently for different animations and is deterministic for the same t. (jsdom has no WebGL: do NOT instantiate the renderer in tests; the scene hook is only type-checked.)

B. Screen and persistence (owner: screen agent)

  • Store: useUserStore().setLook(userId, look: HeroLook, avatarImage?: string) writes users[id].look and, when given, users[id].avatarImage (a PNG data URL), syncs currentUser, persists. getLook(user) helper in src/lib/hero-look/look.ts returns user.look when isHeroLook, else DEFAULT_LOOK.
  • Avatar everywhere: appConfig.$getUserAvatar(user) and XpFabUserHud.userAvatar, UserProfile hero avatar, BattleHeroCard/useBattlePlayer.avatarSrc, XpProfileCard, XpChatMessage, FamilySettings and the SwitchProfile cards must prefer user.avatarImage when present and fall back to the SVG as today. Keep it to a small helper heroAvatarSrc(user, requireAvatar) in src/lib/hero-look/look.ts and use it in those places.
  • My Look screen: route my-look under the portal (/my-portal/:userId/my-look, see src/router/Console/my-portal.routes.ts), page src/app/Console/MyPortal/UserProfile/MyLook/MyLook.vue (+ .ts/.scss): top half <XpHeroModel :look="draft" :animation="preview" interactive /> on a bg-slide background inside an eb-box, a row of animation chips (Idle, Wave, Walk, Dance, Victory) to preview, bottom half an RPG-styled tab strip (Body, Hair, Face, Outfit, Hat) with pickers: style grid (big buttons with the style name, Apple Kid font) and a colour grid (LOOK_COLORS swatches, SKIN_TONES for body). Buttons: Save look (calls setLook(userId, draft, await model.snapshot(256)), toast "Looking good!", plays yes), Reset (DEFAULT_LOOK). Unsaved changes stay in the draft until Save; leaving without saving discards.
  • Entry point: on the profile page (UserProfile.vue hero section) add a button under the avatar: "My Look" (fad fa-tshirt) that routes to my-look; the profile avatar shows the snapshot when present.
  • Hands: the look's leftHand/rightHand mirror user.equipment.leftHand[0]?.faIcon / right at render time (compute in the page; do not store).
  • Tests: tests/unit/HeroLook.spec.ts (setLook persists look and avatarImage through the store; getLook falls back to DEFAULT_LOOK; heroAvatarSrc prefers the image).

Rules for both

Edit only your files; the screen agent imports XpHeroModel from @/components/organisms/HeroModel/XpHeroModel.vue and treats the contract as given even before the file exists. pnpm typecheck clean, pnpm lint 0 errors, your spec green. No git, no dev server. Keep the Earthbound styling (eb-box mixin, Apple Kid / StatusPlz fonts, cream text) like the rest of the app.

⚠️ **GitHub.com Fallback** ⚠️