Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PSX Character Creator</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside class="panel panel--left" id="controls">
|
||||
<header class="panel__header">
|
||||
<h1>Character Creator</h1>
|
||||
<p class="panel__sub">body · parts · palette → GLB</p>
|
||||
</header>
|
||||
|
||||
<section class="section">
|
||||
<h2>Preset</h2>
|
||||
<label class="field">
|
||||
<span>Load cast recipe</span>
|
||||
<select id="preset"></select>
|
||||
</label>
|
||||
<div class="row">
|
||||
<label class="field grow">
|
||||
<span>Name</span>
|
||||
<input id="name" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field grow">
|
||||
<span>Id</span>
|
||||
<input id="id" type="text" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Body</h2>
|
||||
<div class="seg" id="body" role="group" aria-label="Body type">
|
||||
<button type="button" data-body="male" class="seg__btn is-active">Male</button>
|
||||
<button type="button" data-body="female" class="seg__btn">Female</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Height <em id="height-label">1.72 m</em></span>
|
||||
<input id="height" type="range" min="1.45" max="2.05" step="0.01" value="1.72" />
|
||||
</label>
|
||||
<div class="body-style" id="body-style" aria-label="Physique">
|
||||
<label class="field field--slider">
|
||||
<span>Mass <em id="mass-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Lean</i><i>Heavy</i></div>
|
||||
<input id="mass" type="range" min="-1" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<label class="field field--slider">
|
||||
<span>Muscle <em id="muscle-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Soft</i><i>Muscular</i></div>
|
||||
<input id="muscle" type="range" min="-1" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<label class="field field--slider">
|
||||
<span>Fat <em id="fat-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Base</i><i>Heavy</i></div>
|
||||
<input id="fat" type="range" min="0" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<button type="button" class="btn btn--ghost btn--small" id="body-style-reset" title="Reset physique to average">
|
||||
Reset physique
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Skin tone</span>
|
||||
<div class="swatches" id="skin-swatches"></div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Face</h2>
|
||||
<p class="hint">
|
||||
Male and female bases already have their own skulls; these reshape
|
||||
whichever one is selected.
|
||||
</p>
|
||||
<div class="body-style" id="head-style" aria-label="Face">
|
||||
<label class="field field--slider">
|
||||
<span>Face length <em id="face-length-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Round</i><i>Long</i></div>
|
||||
<input id="face-length" type="range" min="-1" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<label class="field field--slider">
|
||||
<span>Jaw <em id="face-jaw-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Tapered</i><i>Square</i></div>
|
||||
<input id="face-jaw" type="range" min="-1" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<label class="field field--slider">
|
||||
<span>Brow <em id="face-brow-label">0.00</em></span>
|
||||
<div class="slider-ends"><i>Soft</i><i>Heavy</i></div>
|
||||
<input id="face-brow" type="range" min="-1" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<button type="button" class="btn btn--ghost btn--small" id="head-style-reset" title="Reset face to the neutral head for this body">
|
||||
Reset face
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Parts</h2>
|
||||
<div id="parts"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Palette</h2>
|
||||
<div class="palette" id="palette"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Texture</h2>
|
||||
<p class="hint">
|
||||
Unwrap UVs, bake a guide sheet, then paint albedo with Grok Imagine
|
||||
(dev server). Results are cached by prompt — the prompt doesn't
|
||||
include the mesh, so after reshaping a body or face you need
|
||||
Regenerate to see it repainted.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Wardrobe / style notes</span>
|
||||
<textarea
|
||||
id="style-notes"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
placeholder="charcoal three-piece business suit, white shirt, burgundy tie, polished black oxfords"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Steers colour, material and detailing only — the cut is fixed by the
|
||||
equipped parts, so pick a suit-shaped upper first. Editing this
|
||||
invalidates the cached albedo by itself.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" id="uv-guide" title="Cylindrical unwrap + UV guide PNG">
|
||||
Bake UV guide
|
||||
</button>
|
||||
<button type="button" class="btn btn--accent" id="texture-grok" title="UV guide → Grok Imagine → textured GLB (uses the cached albedo if there is one)">
|
||||
Texture with Grok
|
||||
</button>
|
||||
<button type="button" class="btn" id="texture-regen" title="Repaint with Grok, ignoring and overwriting the cached albedo — costs a fresh generation">
|
||||
Regenerate texture
|
||||
</button>
|
||||
<button type="button" class="btn" id="albedo-import" title="Apply an albedo PNG/JPEG you painted yourself — no Grok, no cost">
|
||||
Import albedo…
|
||||
</button>
|
||||
<input id="albedo-file" type="file" accept="image/png,image/jpeg" hidden />
|
||||
</div>
|
||||
<p class="hint">
|
||||
Import applies any image painted over the UV guide, so you can use a
|
||||
different generator or paint it by hand.
|
||||
</p>
|
||||
<div class="tex-previews" id="tex-previews" hidden>
|
||||
<figure class="tex-card">
|
||||
<figcaption>UV guide</figcaption>
|
||||
<a id="uv-guide-link" href="#" target="_blank" rel="noreferrer">
|
||||
<img id="uv-guide-img" alt="UV guide" />
|
||||
</a>
|
||||
</figure>
|
||||
<figure class="tex-card" id="albedo-card" hidden>
|
||||
<figcaption>Albedo</figcaption>
|
||||
<a id="albedo-link" href="#" target="_blank" rel="noreferrer">
|
||||
<img id="albedo-img" alt="Albedo texture" />
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
<p class="status" id="tex-status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="section section--actions">
|
||||
<h2>Export</h2>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn--primary" id="download-glb">Download GLB</button>
|
||||
<button type="button" class="btn" id="download-recipe">Download recipe</button>
|
||||
<button type="button" class="btn" id="copy-recipe">Copy recipe JSON</button>
|
||||
<button type="button" class="btn btn--accent" id="save-assets" title="Writes to assets/characters/ (dev server only)">
|
||||
Save to assets/
|
||||
</button>
|
||||
</div>
|
||||
<p class="status" id="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="viewport"></canvas>
|
||||
<div class="stage__hud">
|
||||
<div class="stage__stats" id="stats">Assembling…</div>
|
||||
<div class="stage__controls">
|
||||
<label class="inline">
|
||||
<span>Clip</span>
|
||||
<select id="clip">
|
||||
<option value="Idle">Idle</option>
|
||||
<option value="Walk">Walk</option>
|
||||
<option value="Run">Run</option>
|
||||
<option value="QuickTurn">QuickTurn</option>
|
||||
<option value="Slumped">Slumped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline checkbox">
|
||||
<input type="checkbox" id="auto-rotate" checked />
|
||||
<span>Spin</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="panel panel--right">
|
||||
<header class="panel__header">
|
||||
<h2>Recipe</h2>
|
||||
</header>
|
||||
<pre id="recipe-json" class="code"></pre>
|
||||
</aside>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
BackSide,
|
||||
BoxGeometry,
|
||||
CircleGeometry,
|
||||
Color,
|
||||
ConeGeometry,
|
||||
CylinderGeometry,
|
||||
FogExp2,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshLambertMaterial,
|
||||
PlaneGeometry,
|
||||
Scene,
|
||||
type Material,
|
||||
} from "three";
|
||||
|
||||
/** Muted outdoor palette — fog-choked manor grounds, not neon. */
|
||||
const PALETTE = {
|
||||
skyTop: 0x6a7a72,
|
||||
skyHorizon: 0xa8a890,
|
||||
fog: 0x8a9080,
|
||||
grass: 0x3d4a32,
|
||||
grassDark: 0x2a3424,
|
||||
dirt: 0x5c4a32,
|
||||
dirtLight: 0x6e5a3e,
|
||||
path: 0x6a5c48,
|
||||
bark: 0x3a2e22,
|
||||
foliage: 0x2f3d28,
|
||||
foliageDark: 0x243020,
|
||||
rock: 0x5a5850,
|
||||
rockDark: 0x3e3c36,
|
||||
trunk: 0x2c241c,
|
||||
};
|
||||
|
||||
function lambert(color: number, opts: { flat?: boolean; side?: typeof BackSide } = {}): MeshLambertMaterial {
|
||||
return new MeshLambertMaterial({
|
||||
color,
|
||||
flatShading: opts.flat ?? true,
|
||||
...(opts.side !== undefined ? { side: opts.side } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function addMesh(
|
||||
parent: Group,
|
||||
geometry: ConstructorParameters<typeof Mesh>[0],
|
||||
material: Material,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx = 1,
|
||||
sy = 1,
|
||||
sz = 1,
|
||||
rotY = 0,
|
||||
): Mesh {
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.position.set(x, y, z);
|
||||
mesh.scale.set(sx, sy, sz);
|
||||
mesh.rotation.y = rotY;
|
||||
mesh.receiveShadow = false;
|
||||
mesh.castShadow = false;
|
||||
parent.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** Low-poly pine: trunk + stacked cones. */
|
||||
function makeTree(foliage: MeshLambertMaterial, bark: MeshLambertMaterial, scale = 1): Group {
|
||||
const g = new Group();
|
||||
const trunk = new Mesh(new CylinderGeometry(0.08, 0.12, 0.7, 5), bark);
|
||||
trunk.position.y = 0.35;
|
||||
g.add(trunk);
|
||||
|
||||
const tiers = [
|
||||
{ y: 0.85, r: 0.55, h: 0.7 },
|
||||
{ y: 1.25, r: 0.42, h: 0.6 },
|
||||
{ y: 1.55, r: 0.28, h: 0.5 },
|
||||
];
|
||||
for (const tier of tiers) {
|
||||
const cone = new Mesh(new ConeGeometry(tier.r, tier.h, 6), foliage);
|
||||
cone.position.y = tier.y;
|
||||
g.add(cone);
|
||||
}
|
||||
g.scale.setScalar(scale);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Dead / bare tree — just trunk and a few branch boxes. */
|
||||
function makeDeadTree(bark: MeshLambertMaterial, scale = 1): Group {
|
||||
const g = new Group();
|
||||
const trunk = new Mesh(new CylinderGeometry(0.06, 0.1, 1.4, 5), bark);
|
||||
trunk.position.y = 0.7;
|
||||
g.add(trunk);
|
||||
|
||||
const arm = new Mesh(new BoxGeometry(0.7, 0.07, 0.07), bark);
|
||||
arm.position.set(0.2, 1.15, 0);
|
||||
arm.rotation.z = -0.5;
|
||||
g.add(arm);
|
||||
|
||||
const arm2 = new Mesh(new BoxGeometry(0.45, 0.06, 0.06), bark);
|
||||
arm2.position.set(-0.15, 1.35, 0.05);
|
||||
arm2.rotation.z = 0.65;
|
||||
g.add(arm2);
|
||||
|
||||
g.scale.setScalar(scale);
|
||||
return g;
|
||||
}
|
||||
|
||||
function makeRock(rock: MeshLambertMaterial, rockDark: MeshLambertMaterial, scale = 1): Group {
|
||||
const g = new Group();
|
||||
const a = new Mesh(new BoxGeometry(0.5, 0.28, 0.4), rock);
|
||||
a.position.y = 0.12;
|
||||
a.rotation.y = 0.3;
|
||||
g.add(a);
|
||||
const b = new Mesh(new BoxGeometry(0.28, 0.18, 0.32), rockDark);
|
||||
b.position.set(0.18, 0.08, 0.1);
|
||||
g.add(b);
|
||||
g.scale.setScalar(scale);
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fixed outdoor set for the character creator:
|
||||
* grass clearing, dirt path, pines, rocks, fog sky.
|
||||
* Keeps triangle counts low so the preview stays snappy.
|
||||
*/
|
||||
export function buildEnvironment(scene: Scene): Group {
|
||||
const root = new Group();
|
||||
root.name = "environment";
|
||||
|
||||
// --- atmosphere ----------------------------------------------------------
|
||||
scene.background = new Color(PALETTE.skyHorizon);
|
||||
scene.fog = new FogExp2(PALETTE.fog, 0.085);
|
||||
|
||||
// Gradient sky dome (inside of a sphere-ish cone stack of rings via large sphere).
|
||||
const skyGeo = new ConeGeometry(18, 14, 8, 1, true);
|
||||
// Cone points up; flip so we stand inside a bowl of sky.
|
||||
const sky = new Mesh(skyGeo, lambert(PALETTE.skyTop, { side: BackSide, flat: true }));
|
||||
sky.position.y = 5;
|
||||
sky.rotation.x = Math.PI;
|
||||
// Paint horizon band with a second inverted cone / ring.
|
||||
root.add(sky);
|
||||
|
||||
const horizon = new Mesh(
|
||||
new CylinderGeometry(17, 17, 4, 10, 1, true),
|
||||
lambert(PALETTE.skyHorizon, { side: BackSide, flat: true }),
|
||||
);
|
||||
horizon.position.y = 1.5;
|
||||
root.add(horizon);
|
||||
|
||||
// --- ground --------------------------------------------------------------
|
||||
const grassMat = lambert(PALETTE.grass);
|
||||
const grassDarkMat = lambert(PALETTE.grassDark);
|
||||
const dirtMat = lambert(PALETTE.dirt);
|
||||
const pathMat = lambert(PALETTE.path);
|
||||
|
||||
// Large grass disc (circle reads softer than a huge square at the fog edge).
|
||||
const ground = new Mesh(new CircleGeometry(14, 12), grassMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = 0;
|
||||
root.add(ground);
|
||||
|
||||
// Darker grass patches.
|
||||
for (const [x, z, s] of [
|
||||
[-2.2, -1.8, 1.4],
|
||||
[2.8, 1.2, 1.1],
|
||||
[-1.0, 3.0, 1.3],
|
||||
[3.5, -2.5, 0.9],
|
||||
] as const) {
|
||||
const patch = new Mesh(new CircleGeometry(s, 6), grassDarkMat);
|
||||
patch.rotation.x = -Math.PI / 2;
|
||||
patch.position.set(x, 0.005, z);
|
||||
root.add(patch);
|
||||
}
|
||||
|
||||
// Dirt clearing under the character.
|
||||
const clearing = new Mesh(new CircleGeometry(1.35, 8), dirtMat);
|
||||
clearing.rotation.x = -Math.PI / 2;
|
||||
clearing.position.y = 0.01;
|
||||
root.add(clearing);
|
||||
|
||||
// Path leading toward camera / back.
|
||||
const path = new Mesh(new PlaneGeometry(1.1, 8), pathMat);
|
||||
path.rotation.x = -Math.PI / 2;
|
||||
path.position.set(0, 0.012, 3.2);
|
||||
root.add(path);
|
||||
|
||||
const pathBack = new Mesh(new PlaneGeometry(0.9, 5), pathMat);
|
||||
pathBack.rotation.x = -Math.PI / 2;
|
||||
pathBack.position.set(0.4, 0.012, -3.5);
|
||||
pathBack.rotation.z = 0.15;
|
||||
root.add(pathBack);
|
||||
|
||||
// --- props ---------------------------------------------------------------
|
||||
const bark = lambert(PALETTE.bark);
|
||||
const foliage = lambert(PALETTE.foliage);
|
||||
const foliageDark = lambert(PALETTE.foliageDark);
|
||||
const rock = lambert(PALETTE.rock);
|
||||
const rockDark = lambert(PALETTE.rockDark);
|
||||
const trunk = lambert(PALETTE.trunk);
|
||||
|
||||
const trees: Array<[number, number, number, number]> = [
|
||||
[-3.2, -2.4, 1.15, 0.4],
|
||||
[-4.5, 1.2, 1.35, 1.1],
|
||||
[3.8, -1.6, 1.05, -0.3],
|
||||
[4.2, 2.0, 1.4, 0.8],
|
||||
[-2.8, 3.6, 0.95, 0.2],
|
||||
[2.5, 4.0, 1.2, -0.6],
|
||||
[-5.5, -0.5, 1.5, 0],
|
||||
[5.0, -3.2, 1.1, 1.4],
|
||||
];
|
||||
trees.forEach(([x, z, s, rot], i) => {
|
||||
const tree = makeTree(i % 2 === 0 ? foliage : foliageDark, bark, s);
|
||||
tree.position.set(x, 0, z);
|
||||
tree.rotation.y = rot;
|
||||
root.add(tree);
|
||||
});
|
||||
|
||||
// A couple of bare trees for RE / SH silhouette variety.
|
||||
for (const [x, z, s, rot] of [
|
||||
[-4.0, 3.8, 1.1, 0.5],
|
||||
[4.8, 0.5, 0.95, -0.8],
|
||||
] as const) {
|
||||
const dead = makeDeadTree(trunk, s);
|
||||
dead.position.set(x, 0, z);
|
||||
dead.rotation.y = rot;
|
||||
root.add(dead);
|
||||
}
|
||||
|
||||
for (const [x, z, s, rot] of [
|
||||
[1.6, -1.1, 0.7, 0.2],
|
||||
[-1.4, -1.4, 0.9, 1.0],
|
||||
[2.2, 1.8, 0.55, -0.4],
|
||||
[-2.5, 0.8, 0.65, 0.7],
|
||||
[0.8, 2.4, 0.45, 0.1],
|
||||
] as const) {
|
||||
const r = makeRock(rock, rockDark, s);
|
||||
r.position.set(x, 0, z);
|
||||
r.rotation.y = rot;
|
||||
root.add(r);
|
||||
}
|
||||
|
||||
// Low hedge / bush clumps (cones).
|
||||
for (const [x, z, s] of [
|
||||
[-1.8, 2.2, 0.45],
|
||||
[1.9, -2.0, 0.38],
|
||||
[-3.5, -3.0, 0.5],
|
||||
] as const) {
|
||||
const bush = new Mesh(new ConeGeometry(0.45 * s * 2, 0.5 * s * 2, 5), foliageDark);
|
||||
bush.position.set(x, 0.2 * s * 2, z);
|
||||
root.add(bush);
|
||||
}
|
||||
|
||||
// Distant low hills (flattened boxes at fog edge).
|
||||
const hillMat = lambert(PALETTE.grassDark);
|
||||
for (const [x, z, sx, sy, sz] of [
|
||||
[-8, -6, 5, 1.2, 3],
|
||||
[7, -7, 4, 0.9, 3.5],
|
||||
[-6, 8, 6, 1.4, 3],
|
||||
[9, 5, 4, 1.1, 4],
|
||||
] as const) {
|
||||
addMesh(root, new BoxGeometry(1, 1, 1), hillMat, x, sy * 0.35, z, sx, sy, sz);
|
||||
}
|
||||
|
||||
scene.add(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export { PALETTE as ENV_PALETTE };
|
||||
@@ -0,0 +1,769 @@
|
||||
import { assembleCharacter } from "../../src/creator/assemble";
|
||||
import { SKIN } from "../../src/creator/body";
|
||||
import {
|
||||
BODY_STYLE_DEFAULTS,
|
||||
normalizeBodyStyle,
|
||||
type BodyStyle,
|
||||
} from "../../src/creator/bodyStyle";
|
||||
import {
|
||||
HEAD_STYLE_DEFAULTS,
|
||||
normalizeHeadStyle,
|
||||
type HeadStyle,
|
||||
} from "../../src/creator/headStyle";
|
||||
import { PART_DEFINITIONS } from "../../src/creator/parts";
|
||||
import { CHARACTER_PRESETS } from "../../src/creator/presets";
|
||||
import type {
|
||||
BodyType,
|
||||
CharacterRecipe,
|
||||
CreatorPalette,
|
||||
PartSlot,
|
||||
} from "../../src/creator/types";
|
||||
import type { Rgb } from "../../src/image/Raster";
|
||||
import { CharacterPreview } from "./preview";
|
||||
|
||||
const SLOTS: PartSlot[] = ["hair", "upper", "lower", "feet", "accessory"];
|
||||
const PALETTE_KEYS: (keyof CreatorPalette)[] = [
|
||||
"skin",
|
||||
"hair",
|
||||
"primary",
|
||||
"secondary",
|
||||
"accent",
|
||||
"metal",
|
||||
"leather",
|
||||
];
|
||||
|
||||
const SKIN_OPTIONS: { id: string; name: string; color: Rgb }[] = [
|
||||
{ id: "fair", name: "Fair", color: SKIN.fair },
|
||||
{ id: "light", name: "Light", color: SKIN.light },
|
||||
{ id: "medium", name: "Medium", color: SKIN.medium },
|
||||
{ id: "tan", name: "Tan", color: SKIN.tan },
|
||||
{ id: "deep", name: "Deep", color: SKIN.deep },
|
||||
];
|
||||
|
||||
interface CreatorState {
|
||||
id: string;
|
||||
name: string;
|
||||
body: BodyType;
|
||||
height: number;
|
||||
bodyStyle: BodyStyle;
|
||||
headStyle: HeadStyle;
|
||||
styleNotes: string;
|
||||
palette: CreatorPalette;
|
||||
parts: Partial<Record<PartSlot, string>>;
|
||||
}
|
||||
|
||||
function clonePalette(palette: CreatorPalette): CreatorPalette {
|
||||
return {
|
||||
skin: { ...palette.skin },
|
||||
hair: { ...palette.hair },
|
||||
primary: { ...palette.primary },
|
||||
secondary: { ...palette.secondary },
|
||||
accent: { ...palette.accent },
|
||||
metal: { ...palette.metal },
|
||||
leather: { ...palette.leather },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneBodyStyle(style?: BodyStyle | null): BodyStyle {
|
||||
return normalizeBodyStyle(style ?? BODY_STYLE_DEFAULTS);
|
||||
}
|
||||
|
||||
function cloneHeadStyle(style?: HeadStyle | null): HeadStyle {
|
||||
return normalizeHeadStyle(style ?? HEAD_STYLE_DEFAULTS);
|
||||
}
|
||||
|
||||
function fromPreset(preset: CharacterRecipe): CreatorState {
|
||||
return {
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
body: preset.body,
|
||||
height: preset.height,
|
||||
bodyStyle: cloneBodyStyle(preset.bodyStyle),
|
||||
headStyle: cloneHeadStyle(preset.headStyle),
|
||||
styleNotes: preset.styleNotes ?? "",
|
||||
palette: clonePalette(preset.palette),
|
||||
parts: { ...preset.parts },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultState(): CreatorState {
|
||||
const preset = CHARACTER_PRESETS[0]!;
|
||||
return fromPreset(preset);
|
||||
}
|
||||
|
||||
function rgbToHex({ r, g, b }: Rgb): string {
|
||||
const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): Rgb {
|
||||
const raw = hex.replace("#", "");
|
||||
const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
|
||||
const n = Number.parseInt(full, 16);
|
||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||||
}
|
||||
|
||||
function nearestSkinId(skin: Rgb): string {
|
||||
let best = SKIN_OPTIONS[0]!;
|
||||
let bestDist = Infinity;
|
||||
for (const option of SKIN_OPTIONS) {
|
||||
const d =
|
||||
(option.color.r - skin.r) ** 2 +
|
||||
(option.color.g - skin.g) ** 2 +
|
||||
(option.color.b - skin.b) ** 2;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = option;
|
||||
}
|
||||
}
|
||||
return best.id;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "character";
|
||||
}
|
||||
|
||||
function partOptions(slot: PartSlot, body: BodyType) {
|
||||
return PART_DEFINITIONS.filter((part) => part.slot === slot && (!part.body || part.body === body));
|
||||
}
|
||||
|
||||
function buildRecipe(state: CreatorState): CharacterRecipe {
|
||||
const parts: CharacterRecipe["parts"] = {};
|
||||
for (const slot of SLOTS) {
|
||||
const id = state.parts[slot];
|
||||
if (id) parts[slot] = id;
|
||||
}
|
||||
const bodyStyle = cloneBodyStyle(state.bodyStyle);
|
||||
const headStyle = cloneHeadStyle(state.headStyle);
|
||||
const styleNotes = state.styleNotes.trim();
|
||||
return {
|
||||
id: state.id || slugify(state.name),
|
||||
name: state.name || "Character",
|
||||
body: state.body,
|
||||
height: state.height,
|
||||
bodyStyle,
|
||||
headStyle,
|
||||
...(styleNotes ? { styleNotes } : {}),
|
||||
palette: clonePalette(state.palette),
|
||||
parts,
|
||||
};
|
||||
}
|
||||
|
||||
function formatStyle(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function recipeJson(recipe: CharacterRecipe, stats?: { vertices: number; triangles: number; bones: number; clips: number }): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
...recipe,
|
||||
...(stats ? { stats } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = defaultState();
|
||||
let latestGlb: Uint8Array | null = null;
|
||||
let latestRecipe: CharacterRecipe | null = null;
|
||||
let rebuildTimer = 0;
|
||||
let rebuildToken = 0;
|
||||
|
||||
const el = {
|
||||
preset: document.querySelector<HTMLSelectElement>("#preset")!,
|
||||
name: document.querySelector<HTMLInputElement>("#name")!,
|
||||
id: document.querySelector<HTMLInputElement>("#id")!,
|
||||
body: document.querySelector<HTMLElement>("#body")!,
|
||||
height: document.querySelector<HTMLInputElement>("#height")!,
|
||||
heightLabel: document.querySelector<HTMLElement>("#height-label")!,
|
||||
mass: document.querySelector<HTMLInputElement>("#mass")!,
|
||||
massLabel: document.querySelector<HTMLElement>("#mass-label")!,
|
||||
muscle: document.querySelector<HTMLInputElement>("#muscle")!,
|
||||
muscleLabel: document.querySelector<HTMLElement>("#muscle-label")!,
|
||||
fat: document.querySelector<HTMLInputElement>("#fat")!,
|
||||
fatLabel: document.querySelector<HTMLElement>("#fat-label")!,
|
||||
bodyStyleReset: document.querySelector<HTMLButtonElement>("#body-style-reset")!,
|
||||
faceLength: document.querySelector<HTMLInputElement>("#face-length")!,
|
||||
faceLengthLabel: document.querySelector<HTMLElement>("#face-length-label")!,
|
||||
faceJaw: document.querySelector<HTMLInputElement>("#face-jaw")!,
|
||||
faceJawLabel: document.querySelector<HTMLElement>("#face-jaw-label")!,
|
||||
faceBrow: document.querySelector<HTMLInputElement>("#face-brow")!,
|
||||
faceBrowLabel: document.querySelector<HTMLElement>("#face-brow-label")!,
|
||||
headStyleReset: document.querySelector<HTMLButtonElement>("#head-style-reset")!,
|
||||
skinSwatches: document.querySelector<HTMLElement>("#skin-swatches")!,
|
||||
parts: document.querySelector<HTMLElement>("#parts")!,
|
||||
palette: document.querySelector<HTMLElement>("#palette")!,
|
||||
stats: document.querySelector<HTMLElement>("#stats")!,
|
||||
recipeJson: document.querySelector<HTMLElement>("#recipe-json")!,
|
||||
status: document.querySelector<HTMLElement>("#status")!,
|
||||
clip: document.querySelector<HTMLSelectElement>("#clip")!,
|
||||
autoRotate: document.querySelector<HTMLInputElement>("#auto-rotate")!,
|
||||
downloadGlb: document.querySelector<HTMLButtonElement>("#download-glb")!,
|
||||
downloadRecipe: document.querySelector<HTMLButtonElement>("#download-recipe")!,
|
||||
copyRecipe: document.querySelector<HTMLButtonElement>("#copy-recipe")!,
|
||||
saveAssets: document.querySelector<HTMLButtonElement>("#save-assets")!,
|
||||
uvGuide: document.querySelector<HTMLButtonElement>("#uv-guide")!,
|
||||
textureGrok: document.querySelector<HTMLButtonElement>("#texture-grok")!,
|
||||
textureRegen: document.querySelector<HTMLButtonElement>("#texture-regen")!,
|
||||
albedoImport: document.querySelector<HTMLButtonElement>("#albedo-import")!,
|
||||
albedoFile: document.querySelector<HTMLInputElement>("#albedo-file")!,
|
||||
styleNotes: document.querySelector<HTMLTextAreaElement>("#style-notes")!,
|
||||
texStatus: document.querySelector<HTMLElement>("#tex-status")!,
|
||||
texPreviews: document.querySelector<HTMLElement>("#tex-previews")!,
|
||||
uvGuideImg: document.querySelector<HTMLImageElement>("#uv-guide-img")!,
|
||||
uvGuideLink: document.querySelector<HTMLAnchorElement>("#uv-guide-link")!,
|
||||
albedoCard: document.querySelector<HTMLElement>("#albedo-card")!,
|
||||
albedoImg: document.querySelector<HTMLImageElement>("#albedo-img")!,
|
||||
albedoLink: document.querySelector<HTMLAnchorElement>("#albedo-link")!,
|
||||
viewport: document.querySelector<HTMLCanvasElement>("#viewport")!,
|
||||
};
|
||||
|
||||
const preview = new CharacterPreview(el.viewport);
|
||||
preview.setAutoRotate(el.autoRotate.checked);
|
||||
|
||||
function setStatus(message: string, kind: "ok" | "err" | "" = ""): void {
|
||||
el.status.textContent = message;
|
||||
el.status.classList.remove("is-ok", "is-err");
|
||||
if (kind === "ok") el.status.classList.add("is-ok");
|
||||
if (kind === "err") el.status.classList.add("is-err");
|
||||
}
|
||||
|
||||
function setTexStatus(message: string, kind: "ok" | "err" | "" = ""): void {
|
||||
el.texStatus.textContent = message;
|
||||
el.texStatus.classList.remove("is-ok", "is-err");
|
||||
if (kind === "ok") el.texStatus.classList.add("is-ok");
|
||||
if (kind === "err") el.texStatus.classList.add("is-err");
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function setTextureBusy(busy: boolean): void {
|
||||
el.uvGuide.disabled = busy;
|
||||
el.textureGrok.disabled = busy;
|
||||
el.textureRegen.disabled = busy;
|
||||
el.albedoImport.disabled = busy;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
interface TextureRun {
|
||||
mode: "guide" | "texture";
|
||||
force?: boolean;
|
||||
/** Apply this image as the albedo instead of calling Grok. */
|
||||
albedo?: File;
|
||||
}
|
||||
|
||||
async function runTexture({ mode, force = false, albedo }: TextureRun): Promise<void> {
|
||||
const recipe = buildRecipe(state);
|
||||
setTextureBusy(true);
|
||||
setTexStatus(
|
||||
albedo
|
||||
? `Applying ${albedo.name}…`
|
||||
: mode === "guide"
|
||||
? "Baking UV guide…"
|
||||
: force
|
||||
? "Repainting with Grok Imagine, ignoring the cache… (can take ~1 min)"
|
||||
: "Texturing with Grok Imagine… (can take ~1 min)",
|
||||
);
|
||||
|
||||
try {
|
||||
const albedoBase64 = albedo
|
||||
? bytesToBase64(new Uint8Array(await albedo.arrayBuffer()))
|
||||
: undefined;
|
||||
const response = await fetch("/api/texture", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipe, mode, force, albedoBase64 }),
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
glbBase64?: string;
|
||||
guideUrl?: string;
|
||||
albedoUrl?: string | null;
|
||||
textured?: boolean;
|
||||
imported?: boolean;
|
||||
cached?: boolean;
|
||||
costUsd?: number;
|
||||
bytes?: number;
|
||||
};
|
||||
if (!response.ok || !body.ok || !body.glbBase64) {
|
||||
throw new Error(body.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
latestGlb = base64ToBytes(body.glbBase64);
|
||||
latestRecipe = recipe;
|
||||
|
||||
if (body.guideUrl) {
|
||||
el.texPreviews.hidden = false;
|
||||
el.uvGuideImg.src = body.guideUrl;
|
||||
el.uvGuideLink.href = body.guideUrl;
|
||||
}
|
||||
if (body.albedoUrl) {
|
||||
el.albedoCard.hidden = false;
|
||||
el.albedoImg.src = body.albedoUrl;
|
||||
el.albedoLink.href = body.albedoUrl;
|
||||
} else {
|
||||
el.albedoCard.hidden = true;
|
||||
}
|
||||
|
||||
await preview.setGlb(latestGlb, el.clip.value);
|
||||
|
||||
const bits = [
|
||||
mode === "guide"
|
||||
? "UV guide ready"
|
||||
: !body.textured
|
||||
? "Done"
|
||||
: body.imported
|
||||
? "Albedo imported"
|
||||
: force
|
||||
? "Repainted — cache replaced"
|
||||
: "Textured GLB ready",
|
||||
body.bytes ? `${(body.bytes / 1024).toFixed(0)} KB` : "",
|
||||
body.cached ? "cached" : "",
|
||||
body.costUsd && body.costUsd > 0 ? `$${body.costUsd.toFixed(3)}` : "",
|
||||
].filter(Boolean);
|
||||
setTexStatus(bits.join(" · "), "ok");
|
||||
el.stats.textContent = [
|
||||
body.textured
|
||||
? body.imported
|
||||
? "Textured (imported albedo)"
|
||||
: "Textured (Grok albedo)"
|
||||
: "UV guide baked (vertex colour GLB)",
|
||||
body.bytes ? `${(body.bytes / 1024).toFixed(1)} KB` : "",
|
||||
recipe.body + " @ " + recipe.height.toFixed(2) + " m",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTexStatus(message, "err");
|
||||
} finally {
|
||||
setTextureBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function syncBodyStyleControls(): void {
|
||||
el.mass.value = String(state.bodyStyle.mass);
|
||||
el.muscle.value = String(state.bodyStyle.muscle);
|
||||
el.fat.value = String(state.bodyStyle.fat);
|
||||
el.massLabel.textContent = formatStyle(state.bodyStyle.mass);
|
||||
el.muscleLabel.textContent = formatStyle(state.bodyStyle.muscle);
|
||||
el.fatLabel.textContent = formatStyle(state.bodyStyle.fat);
|
||||
}
|
||||
|
||||
function syncHeadStyleControls(): void {
|
||||
el.faceLength.value = String(state.headStyle.length);
|
||||
el.faceJaw.value = String(state.headStyle.jaw);
|
||||
el.faceBrow.value = String(state.headStyle.brow);
|
||||
el.faceLengthLabel.textContent = formatStyle(state.headStyle.length);
|
||||
el.faceJawLabel.textContent = formatStyle(state.headStyle.jaw);
|
||||
el.faceBrowLabel.textContent = formatStyle(state.headStyle.brow);
|
||||
}
|
||||
|
||||
function syncFormFromState(): void {
|
||||
el.name.value = state.name;
|
||||
el.id.value = state.id;
|
||||
el.height.value = String(state.height);
|
||||
el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
|
||||
syncBodyStyleControls();
|
||||
syncHeadStyleControls();
|
||||
el.styleNotes.value = state.styleNotes;
|
||||
|
||||
for (const btn of el.body.querySelectorAll<HTMLButtonElement>(".seg__btn")) {
|
||||
btn.classList.toggle("is-active", btn.dataset.body === state.body);
|
||||
}
|
||||
|
||||
const skinId = nearestSkinId(state.palette.skin);
|
||||
for (const swatch of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
|
||||
swatch.classList.toggle("is-active", swatch.dataset.skin === skinId);
|
||||
}
|
||||
|
||||
for (const key of PALETTE_KEYS) {
|
||||
const input = el.palette.querySelector<HTMLInputElement>(`input[data-key="${key}"]`);
|
||||
if (input) input.value = rgbToHex(state.palette[key]);
|
||||
}
|
||||
|
||||
renderParts();
|
||||
}
|
||||
|
||||
function renderPresets(): void {
|
||||
el.preset.innerHTML = "";
|
||||
const blank = document.createElement("option");
|
||||
blank.value = "";
|
||||
blank.textContent = "— custom —";
|
||||
el.preset.append(blank);
|
||||
|
||||
for (const preset of CHARACTER_PRESETS) {
|
||||
const option = document.createElement("option");
|
||||
option.value = preset.id;
|
||||
option.textContent = `${preset.name} (${preset.id})`;
|
||||
el.preset.append(option);
|
||||
}
|
||||
el.preset.value = state.id;
|
||||
}
|
||||
|
||||
function renderSkinSwatches(): void {
|
||||
el.skinSwatches.innerHTML = "";
|
||||
for (const option of SKIN_OPTIONS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "swatch";
|
||||
btn.title = option.name;
|
||||
btn.dataset.skin = option.id;
|
||||
btn.style.background = rgbToHex(option.color);
|
||||
btn.addEventListener("click", () => {
|
||||
state.palette.skin = { ...option.color };
|
||||
const skinInput = el.palette.querySelector<HTMLInputElement>(`input[data-key="skin"]`);
|
||||
if (skinInput) skinInput.value = rgbToHex(option.color);
|
||||
for (const s of el.skinSwatches.querySelectorAll(".swatch")) {
|
||||
s.classList.toggle("is-active", s === btn);
|
||||
}
|
||||
scheduleRebuild();
|
||||
});
|
||||
el.skinSwatches.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPalette(): void {
|
||||
el.palette.innerHTML = "";
|
||||
for (const key of PALETTE_KEYS) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "color-field";
|
||||
const span = document.createElement("span");
|
||||
span.textContent = key;
|
||||
const input = document.createElement("input");
|
||||
input.type = "color";
|
||||
input.dataset.key = key;
|
||||
input.value = rgbToHex(state.palette[key]);
|
||||
input.addEventListener("input", () => {
|
||||
state.palette[key] = hexToRgb(input.value);
|
||||
if (key === "skin") {
|
||||
const skinId = nearestSkinId(state.palette.skin);
|
||||
for (const s of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
|
||||
s.classList.toggle("is-active", s.dataset.skin === skinId);
|
||||
}
|
||||
}
|
||||
scheduleRebuild();
|
||||
});
|
||||
label.append(span, input);
|
||||
el.palette.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
function renderParts(): void {
|
||||
el.parts.innerHTML = "";
|
||||
for (const slot of SLOTS) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "part-slot";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "part-slot__label";
|
||||
label.textContent = slot;
|
||||
wrap.append(label);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "chip-row";
|
||||
|
||||
const options = partOptions(slot, state.body);
|
||||
const current = state.parts[slot] ?? `${slot}.none`;
|
||||
|
||||
// If current part is invalid for body (e.g. skirt on male), clear it.
|
||||
if (!options.some((p) => p.id === current)) {
|
||||
state.parts[slot] = options[0]?.id ?? `${slot}.none`;
|
||||
}
|
||||
|
||||
for (const part of options) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "chip";
|
||||
chip.textContent = part.name;
|
||||
chip.dataset.part = part.id;
|
||||
if (part.id === (state.parts[slot] ?? `${slot}.none`)) chip.classList.add("is-active");
|
||||
chip.addEventListener("click", () => {
|
||||
state.parts[slot] = part.id;
|
||||
for (const c of row.querySelectorAll(".chip")) c.classList.remove("is-active");
|
||||
chip.classList.add("is-active");
|
||||
scheduleRebuild();
|
||||
});
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
wrap.append(row);
|
||||
el.parts.append(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRebuild(): void {
|
||||
window.clearTimeout(rebuildTimer);
|
||||
rebuildTimer = window.setTimeout(() => {
|
||||
void rebuild();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
async function rebuild(): Promise<void> {
|
||||
const token = ++rebuildToken;
|
||||
const recipe = buildRecipe(state);
|
||||
el.recipeJson.textContent = recipeJson(recipe);
|
||||
el.stats.textContent = "Assembling…";
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const result = assembleCharacter(recipe);
|
||||
if (token !== rebuildToken) return;
|
||||
|
||||
latestGlb = result.glb;
|
||||
latestRecipe = result.recipe;
|
||||
const ms = performance.now() - t0;
|
||||
|
||||
el.recipeJson.textContent = recipeJson(result.recipe, result.stats);
|
||||
const bs = result.recipe.bodyStyle ?? BODY_STYLE_DEFAULTS;
|
||||
const hs = result.recipe.headStyle ?? HEAD_STYLE_DEFAULTS;
|
||||
el.stats.textContent = [
|
||||
`${result.stats.vertices} verts · ${result.stats.triangles} tris · ${result.stats.bones} bones`,
|
||||
`${(result.glb.byteLength / 1024).toFixed(1)} KB · ${ms.toFixed(0)} ms`,
|
||||
`${result.recipe.body} @ ${result.recipe.height.toFixed(2)} m`,
|
||||
`mass ${formatStyle(bs.mass)} · muscle ${formatStyle(bs.muscle)} · fat ${formatStyle(bs.fat)}`,
|
||||
`face ${formatStyle(hs.length)} · jaw ${formatStyle(hs.jaw)} · brow ${formatStyle(hs.brow)}`,
|
||||
].join("\n");
|
||||
|
||||
await preview.setGlb(result.glb, el.clip.value);
|
||||
if (token !== rebuildToken) return;
|
||||
setStatus("");
|
||||
} catch (error) {
|
||||
if (token !== rebuildToken) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
el.stats.textContent = "Assemble failed";
|
||||
setStatus(message, "err");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Events -------------------------------------------------------------------
|
||||
|
||||
el.preset.addEventListener("change", () => {
|
||||
const preset = CHARACTER_PRESETS.find((p) => p.id === el.preset.value);
|
||||
if (!preset) return;
|
||||
Object.assign(state, fromPreset(preset));
|
||||
syncFormFromState();
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.name.addEventListener("input", () => {
|
||||
state.name = el.name.value;
|
||||
// Keep id in sync while it still looks auto-derived.
|
||||
if (!el.id.dataset.manual) {
|
||||
state.id = slugify(state.name);
|
||||
el.id.value = state.id;
|
||||
}
|
||||
el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.id.addEventListener("input", () => {
|
||||
el.id.dataset.manual = "1";
|
||||
state.id = slugify(el.id.value) || el.id.value;
|
||||
el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.body.addEventListener("click", (event) => {
|
||||
const btn = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-body]");
|
||||
if (!btn?.dataset.body) return;
|
||||
state.body = btn.dataset.body as BodyType;
|
||||
for (const b of el.body.querySelectorAll(".seg__btn")) b.classList.remove("is-active");
|
||||
btn.classList.add("is-active");
|
||||
renderParts();
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.height.addEventListener("input", () => {
|
||||
state.height = Number(el.height.value);
|
||||
el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
function bindStyleSlider(
|
||||
input: HTMLInputElement,
|
||||
label: HTMLElement,
|
||||
key: keyof BodyStyle,
|
||||
): void {
|
||||
input.addEventListener("input", () => {
|
||||
state.bodyStyle[key] = Number(input.value);
|
||||
label.textContent = formatStyle(state.bodyStyle[key]);
|
||||
scheduleRebuild();
|
||||
});
|
||||
}
|
||||
|
||||
bindStyleSlider(el.mass, el.massLabel, "mass");
|
||||
bindStyleSlider(el.muscle, el.muscleLabel, "muscle");
|
||||
bindStyleSlider(el.fat, el.fatLabel, "fat");
|
||||
|
||||
function bindFaceSlider(
|
||||
input: HTMLInputElement,
|
||||
label: HTMLElement,
|
||||
key: keyof HeadStyle,
|
||||
): void {
|
||||
input.addEventListener("input", () => {
|
||||
state.headStyle[key] = Number(input.value);
|
||||
label.textContent = formatStyle(state.headStyle[key]);
|
||||
scheduleRebuild();
|
||||
});
|
||||
}
|
||||
|
||||
bindFaceSlider(el.faceLength, el.faceLengthLabel, "length");
|
||||
bindFaceSlider(el.faceJaw, el.faceJawLabel, "jaw");
|
||||
bindFaceSlider(el.faceBrow, el.faceBrowLabel, "brow");
|
||||
|
||||
el.bodyStyleReset.addEventListener("click", () => {
|
||||
state.bodyStyle = cloneBodyStyle(BODY_STYLE_DEFAULTS);
|
||||
syncBodyStyleControls();
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.headStyleReset.addEventListener("click", () => {
|
||||
state.headStyle = cloneHeadStyle(HEAD_STYLE_DEFAULTS);
|
||||
syncHeadStyleControls();
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.clip.addEventListener("change", () => {
|
||||
preview.play(el.clip.value);
|
||||
});
|
||||
|
||||
el.autoRotate.addEventListener("change", () => {
|
||||
preview.setAutoRotate(el.autoRotate.checked);
|
||||
});
|
||||
|
||||
el.downloadGlb.addEventListener("click", () => {
|
||||
if (!latestGlb || !latestRecipe) {
|
||||
setStatus("Nothing to download yet", "err");
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(latestGlb);
|
||||
downloadBlob(
|
||||
`${latestRecipe.id}.glb`,
|
||||
new Blob([bytes], { type: "model/gltf-binary" }),
|
||||
);
|
||||
setStatus(`Downloaded ${latestRecipe.id}.glb`, "ok");
|
||||
});
|
||||
|
||||
el.downloadRecipe.addEventListener("click", () => {
|
||||
if (!latestRecipe) {
|
||||
setStatus("Nothing to download yet", "err");
|
||||
return;
|
||||
}
|
||||
const text = recipeJson(latestRecipe);
|
||||
downloadBlob(`${latestRecipe.id}.recipe.json`, new Blob([text], { type: "application/json" }));
|
||||
setStatus(`Downloaded ${latestRecipe.id}.recipe.json`, "ok");
|
||||
});
|
||||
|
||||
el.copyRecipe.addEventListener("click", async () => {
|
||||
if (!latestRecipe) {
|
||||
setStatus("Nothing to copy yet", "err");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(recipeJson(latestRecipe));
|
||||
setStatus("Recipe JSON copied", "ok");
|
||||
} catch {
|
||||
setStatus("Clipboard blocked — select the JSON panel instead", "err");
|
||||
}
|
||||
});
|
||||
|
||||
el.saveAssets.addEventListener("click", async () => {
|
||||
if (!latestGlb || !latestRecipe) {
|
||||
setStatus("Nothing to save yet", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Saving to assets/characters/…");
|
||||
try {
|
||||
const response = await fetch("/api/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: latestRecipe.id,
|
||||
recipe: {
|
||||
...latestRecipe,
|
||||
},
|
||||
glbBase64: bytesToBase64(latestGlb),
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as { ok?: boolean; error?: string; bytes?: number; glb?: string };
|
||||
if (!response.ok || !body.ok) {
|
||||
throw new Error(body.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
setStatus(`Saved ${latestRecipe.id}.glb (${((body.bytes ?? 0) / 1024).toFixed(1)} KB)`, "ok");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Save failed: ${message}`, "err");
|
||||
}
|
||||
});
|
||||
|
||||
el.styleNotes.addEventListener("input", () => {
|
||||
state.styleNotes = el.styleNotes.value;
|
||||
// Doesn't touch geometry, but keeps the recipe JSON panel truthful.
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
el.uvGuide.addEventListener("click", () => {
|
||||
void runTexture({ mode: "guide" });
|
||||
});
|
||||
|
||||
el.textureGrok.addEventListener("click", () => {
|
||||
void runTexture({ mode: "texture" });
|
||||
});
|
||||
|
||||
el.textureRegen.addEventListener("click", () => {
|
||||
void runTexture({ mode: "texture", force: true });
|
||||
});
|
||||
|
||||
el.albedoImport.addEventListener("click", () => {
|
||||
el.albedoFile.click();
|
||||
});
|
||||
|
||||
el.albedoFile.addEventListener("change", () => {
|
||||
const file = el.albedoFile.files?.[0];
|
||||
// Reset first, so picking the same file twice still fires a change event.
|
||||
el.albedoFile.value = "";
|
||||
if (file) void runTexture({ mode: "texture", albedo: file });
|
||||
});
|
||||
|
||||
// Boot ---------------------------------------------------------------------
|
||||
|
||||
renderPresets();
|
||||
renderSkinSwatches();
|
||||
renderPalette();
|
||||
syncFormFromState();
|
||||
void rebuild();
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
AmbientLight,
|
||||
AnimationMixer,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
Group,
|
||||
HemisphereLight,
|
||||
LoopOnce,
|
||||
LoopRepeat,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshLambertMaterial,
|
||||
NearestFilter,
|
||||
OrthographicCamera,
|
||||
PerspectiveCamera,
|
||||
PlaneGeometry,
|
||||
RGBAFormat,
|
||||
Scene,
|
||||
SkinnedMesh,
|
||||
SRGBColorSpace,
|
||||
WebGLRenderTarget,
|
||||
WebGLRenderer,
|
||||
type AnimationAction,
|
||||
type AnimationClip,
|
||||
type Material,
|
||||
} from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { buildEnvironment, ENV_PALETTE } from "./environment";
|
||||
|
||||
/**
|
||||
* Three.js viewport for the character creator.
|
||||
* Outdoor PSX-style set, fog, sun lighting, and a low-res nearest upscale.
|
||||
*/
|
||||
export class CharacterPreview {
|
||||
private readonly renderer: WebGLRenderer;
|
||||
private readonly scene = new Scene();
|
||||
private readonly camera: PerspectiveCamera;
|
||||
private readonly controls: OrbitControls;
|
||||
private readonly loader = new GLTFLoader();
|
||||
private readonly root = new Group();
|
||||
private readonly clock = { last: performance.now() };
|
||||
|
||||
/** Offscreen low-res buffer → nearest-neighbor blit (GTE-ish look). */
|
||||
private readonly rt: WebGLRenderTarget;
|
||||
private readonly blitScene = new Scene();
|
||||
private readonly blitCam = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
private readonly blitMat: MeshBasicMaterial;
|
||||
private viewW = 1;
|
||||
private viewH = 1;
|
||||
|
||||
private mixer: AnimationMixer | null = null;
|
||||
private actions = new Map<string, AnimationAction>();
|
||||
private character: Group | null = null;
|
||||
private env: Group | null = null;
|
||||
private raf = 0;
|
||||
private autoRotate = true;
|
||||
private disposed = false;
|
||||
private loadGen = 0;
|
||||
|
||||
constructor(private readonly canvas: HTMLCanvasElement) {
|
||||
this.renderer = new WebGLRenderer({
|
||||
canvas,
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
|
||||
this.renderer.setPixelRatio(1);
|
||||
this.renderer.outputColorSpace = SRGBColorSpace;
|
||||
|
||||
this.camera = new PerspectiveCamera(38, 1, 0.08, 28);
|
||||
this.camera.position.set(1.55, 1.25, 2.65);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, canvas);
|
||||
this.controls.target.set(0, 0.85, 0);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.08;
|
||||
this.controls.minDistance = 1.0;
|
||||
this.controls.maxDistance = 7;
|
||||
this.controls.maxPolarAngle = Math.PI * 0.48;
|
||||
this.controls.minPolarAngle = 0.15;
|
||||
|
||||
this.scene.add(this.root);
|
||||
this.env = buildEnvironment(this.scene);
|
||||
this.setupLighting();
|
||||
|
||||
// Low-res render target; size updated on resize.
|
||||
this.rt = new WebGLRenderTarget(320, 240, {
|
||||
format: RGBAFormat,
|
||||
minFilter: NearestFilter,
|
||||
magFilter: NearestFilter,
|
||||
depthBuffer: true,
|
||||
stencilBuffer: false,
|
||||
});
|
||||
this.rt.texture.generateMipmaps = false;
|
||||
|
||||
this.blitMat = new MeshBasicMaterial({
|
||||
map: this.rt.texture,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const blitQuad = new Mesh(new PlaneGeometry(2, 2), this.blitMat);
|
||||
this.blitScene.add(blitQuad);
|
||||
|
||||
this.onResize();
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.tick();
|
||||
}
|
||||
|
||||
private setupLighting(): void {
|
||||
// Cool overcast sky / warm dirt bounce — PSX adventure outdoor.
|
||||
const hemi = new HemisphereLight(0xb0b8a0, 0x3a3220, 0.55);
|
||||
hemi.position.set(0, 4, 0);
|
||||
|
||||
// Late-afternoon key (sun).
|
||||
const sun = new DirectionalLight(0xffe2b0, 1.35);
|
||||
sun.position.set(4.5, 7.5, 3.2);
|
||||
|
||||
// Cool sky fill from the opposite side.
|
||||
const fill = new DirectionalLight(0x8898a8, 0.4);
|
||||
fill.position.set(-3.5, 2.5, -2.5);
|
||||
|
||||
// Soft ambient so flats don't crush to black.
|
||||
const ambient = new AmbientLight(0x4a5040, 0.38);
|
||||
|
||||
// Weak rim so silhouettes read against fog.
|
||||
const rim = new DirectionalLight(0xc8d0c0, 0.25);
|
||||
rim.position.set(-1.5, 3.0, -4.0);
|
||||
|
||||
this.scene.add(hemi, sun, fill, ambient, rim);
|
||||
}
|
||||
|
||||
setAutoRotate(enabled: boolean): void {
|
||||
this.autoRotate = enabled;
|
||||
this.controls.autoRotate = enabled;
|
||||
this.controls.autoRotateSpeed = 1.15;
|
||||
}
|
||||
|
||||
async setGlb(glb: Uint8Array, clipName = "Idle"): Promise<void> {
|
||||
const gen = ++this.loadGen;
|
||||
const gltf = await this.parseGlb(glb);
|
||||
if (this.disposed || gen !== this.loadGen) return;
|
||||
|
||||
if (this.character) {
|
||||
this.root.remove(this.character);
|
||||
this.disposeObject(this.character);
|
||||
this.character = null;
|
||||
}
|
||||
this.mixer = null;
|
||||
this.actions.clear();
|
||||
|
||||
const scene = gltf.scene as Group;
|
||||
scene.traverse((object) => {
|
||||
const mesh = object as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
|
||||
// Keep Grok albedo map when present; otherwise vertex colours.
|
||||
const prior = mesh.material as Material & { map?: import("three").Texture | null };
|
||||
const loadedMap = !Array.isArray(prior) && prior.map ? prior.map : null;
|
||||
|
||||
mesh.material = new MeshLambertMaterial({
|
||||
vertexColors: !loadedMap,
|
||||
flatShading: true,
|
||||
fog: true,
|
||||
...(loadedMap ? { map: loadedMap } : {}),
|
||||
});
|
||||
if (loadedMap) {
|
||||
loadedMap.magFilter = NearestFilter;
|
||||
loadedMap.minFilter = NearestFilter;
|
||||
loadedMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
if ((mesh as SkinnedMesh).isSkinnedMesh) {
|
||||
mesh.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.root.add(scene);
|
||||
this.character = scene;
|
||||
|
||||
this.mixer = new AnimationMixer(scene);
|
||||
for (const clip of gltf.animations) {
|
||||
const action = this.mixer.clipAction(clip);
|
||||
const once = clip.name === "QuickTurn" || clip.name === "Slumped";
|
||||
action.setLoop(once ? LoopOnce : LoopRepeat, Infinity);
|
||||
action.clampWhenFinished = once;
|
||||
this.actions.set(clip.name, action);
|
||||
}
|
||||
|
||||
this.play(clipName);
|
||||
this.mixer.update(0);
|
||||
}
|
||||
|
||||
play(clipName: string): void {
|
||||
if (!this.mixer) return;
|
||||
const next = this.actions.get(clipName);
|
||||
if (!next) return;
|
||||
|
||||
for (const [name, action] of this.actions) {
|
||||
if (name === clipName) continue;
|
||||
if (action.isRunning()) action.fadeOut(0.15);
|
||||
}
|
||||
|
||||
next.reset().fadeIn(0.15).play();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
this.controls.dispose();
|
||||
if (this.character) this.disposeObject(this.character);
|
||||
if (this.env) this.disposeObject(this.env);
|
||||
this.rt.dispose();
|
||||
this.blitMat.dispose();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
|
||||
private parseGlb(glb: Uint8Array): Promise<{ scene: Group; animations: AnimationClip[] }> {
|
||||
const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.loader.parse(buffer, "", (gltf) => resolve(gltf as { scene: Group; animations: AnimationClip[] }), reject);
|
||||
});
|
||||
}
|
||||
|
||||
private disposeObject(root: Group): void {
|
||||
root.traverse((object) => {
|
||||
const mesh = object as Mesh;
|
||||
if (mesh.isMesh) {
|
||||
mesh.geometry?.dispose();
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
||||
else material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readonly onResize = (): void => {
|
||||
const parent = this.canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const width = parent.clientWidth;
|
||||
const height = parent.clientHeight;
|
||||
if (width === 0 || height === 0) return;
|
||||
|
||||
this.viewW = width;
|
||||
this.viewH = height;
|
||||
|
||||
// Internal PSX-ish resolution (~320px wide), keep aspect.
|
||||
const internalW = 320;
|
||||
const internalH = Math.max(180, Math.round(internalW * (height / width)));
|
||||
this.rt.setSize(internalW, internalH);
|
||||
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
// Full canvas for the upscale blit.
|
||||
this.renderer.setSize(width, height, false);
|
||||
};
|
||||
|
||||
private readonly tick = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.raf = requestAnimationFrame(this.tick);
|
||||
|
||||
const now = performance.now();
|
||||
const dt = Math.min(0.05, (now - this.clock.last) / 1000);
|
||||
this.clock.last = now;
|
||||
|
||||
this.mixer?.update(dt);
|
||||
this.controls.autoRotate = this.autoRotate;
|
||||
this.controls.update();
|
||||
|
||||
// Pass 1: scene → low-res RT
|
||||
this.renderer.setRenderTarget(this.rt);
|
||||
this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
// Pass 2: nearest upscale to canvas (chunky pixels).
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.blitScene, this.blitCam);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
:root {
|
||||
--ink: #c8d0c0;
|
||||
--ink-dim: #7d8878;
|
||||
--ink-bright: #eef2e6;
|
||||
--bg: #05060a;
|
||||
--panel: #0a0e0c;
|
||||
--panel-elevated: #101612;
|
||||
--border: #47513f;
|
||||
--border-soft: #2a3228;
|
||||
--accent: #c9a227;
|
||||
--accent-dim: #8a7020;
|
||||
--danger: #a33b2e;
|
||||
--ok: #6a9a5a;
|
||||
--mono: "Courier New", ui-monospace, monospace;
|
||||
|
||||
color-scheme: dark;
|
||||
font-family: var(--mono);
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: none;
|
||||
font-smooth: never;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) 1fr minmax(240px, 300px);
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* --- panels --------------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
border-right: 2px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel--right {
|
||||
border-right: none;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel__header h1,
|
||||
.panel__header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-bright);
|
||||
}
|
||||
|
||||
.panel__sub {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.panel--left {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.section--actions {
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tex-previews {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.tex-card {
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.tex-card figcaption {
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.tex-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.tex-card a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* --- form controls -------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.field span em {
|
||||
font-style: normal;
|
||||
color: var(--ink-bright);
|
||||
float: right;
|
||||
}
|
||||
|
||||
.body-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.6rem 0.45rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.field--slider {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.slider-ends {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 0.1rem;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.slider-ends i {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
border-color: var(--border);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn--small {
|
||||
align-self: flex-start;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-bright);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.seg__btn {
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-dim);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg__btn:hover {
|
||||
color: var(--ink-bright);
|
||||
border-color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.seg__btn.is-active {
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.part-slot {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.part-slot__label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 0.28rem 0.45rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--border);
|
||||
color: var(--ink-bright);
|
||||
}
|
||||
|
||||
.chip.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(201, 162, 39, 0.1);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.palette {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.color-field input[type="color"] {
|
||||
width: 2rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border: 2px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.swatch.is-active {
|
||||
border-color: var(--accent);
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
/* --- actions -------------------------------------------------------------- */
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--panel-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-bright);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: #d4b03a;
|
||||
border-color: #d4b03a;
|
||||
}
|
||||
|
||||
.btn--accent {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 1.2rem;
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status.is-ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.status.is-err {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* --- stage ---------------------------------------------------------------- */
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
/* Fallback while WebGL boots — matches outdoor fog palette. */
|
||||
background: linear-gradient(180deg, #6a7a72 0%, #a8a890 42%, #3d4a32 100%);
|
||||
}
|
||||
|
||||
#viewport {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.stage__hud {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(transparent, rgba(5, 6, 10, 0.85));
|
||||
}
|
||||
|
||||
.stage__stats {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.06em;
|
||||
white-space: pre-line;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stage__controls {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inline select {
|
||||
width: auto;
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- recipe json ---------------------------------------------------------- */
|
||||
|
||||
.panel--right .panel__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
color: var(--ink);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* --- responsive ----------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#app {
|
||||
grid-template-columns: minmax(260px, 300px) 1fr;
|
||||
}
|
||||
|
||||
.panel--right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 45vh 1fr;
|
||||
}
|
||||
|
||||
.panel--left {
|
||||
order: 2;
|
||||
border-right: none;
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.stage {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@creator/*": ["../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
const root = fileURLToPath(new URL(".", import.meta.url));
|
||||
const toolsSrc = resolve(root, "../src");
|
||||
const toolsDir = resolve(root, "..");
|
||||
const workspaceRoot = resolve(root, "../..");
|
||||
const assetsDir = resolve(workspaceRoot, "assets/characters");
|
||||
const textureCli = resolve(toolsSrc, "texture/cli.ts");
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolveBody, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => resolveBody(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function runTextureCli(payload: unknown): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--import", "tsx", textureCli],
|
||||
{
|
||||
cwd: toolsDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
|
||||
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "";
|
||||
try {
|
||||
const json = JSON.parse(line) as Record<string, unknown>;
|
||||
if (code !== 0 && !json["ok"]) {
|
||||
reject(new Error(String(json["error"] ?? (stderr || `texture cli exited ${code}`))));
|
||||
return;
|
||||
}
|
||||
resolvePromise(json);
|
||||
} catch {
|
||||
reject(new Error(stderr || stdout || `texture cli exited ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(payload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Dev-only APIs: save GLB, UV guide / Grok texture, serve character assets. */
|
||||
function creatorApiPlugin(): Plugin {
|
||||
return {
|
||||
name: "psx-creator-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
// GET /assets-characters/<file>
|
||||
if (req.method === "GET" && req.url?.startsWith("/assets-characters/")) {
|
||||
const name = decodeURIComponent(req.url.slice("/assets-characters/".length).split("?")[0] ?? "");
|
||||
if (!name || name.includes("..") || name.includes("/")) {
|
||||
res.statusCode = 400;
|
||||
res.end("bad path");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bytes = await readFile(resolve(assetsDir, name));
|
||||
const ext = extname(name).toLowerCase();
|
||||
const mime =
|
||||
ext === ".png"
|
||||
? "image/png"
|
||||
: ext === ".jpg" || ext === ".jpeg"
|
||||
? "image/jpeg"
|
||||
: ext === ".glb"
|
||||
? "model/gltf-binary"
|
||||
: "application/octet-stream";
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", mime);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.end(bytes);
|
||||
} catch {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.url === "/api/save") {
|
||||
const raw = await readBody(req);
|
||||
const payload = JSON.parse(raw.toString("utf8")) as {
|
||||
id?: string;
|
||||
recipe?: unknown;
|
||||
glbBase64?: string;
|
||||
};
|
||||
const id = payload.id?.replace(/[^a-z0-9_-]/gi, "") ?? "";
|
||||
if (!id || !payload.recipe || !payload.glbBase64) {
|
||||
sendJson(res, 400, { error: "Expected { id, recipe, glbBase64 }" });
|
||||
return;
|
||||
}
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
const glbPath = resolve(assetsDir, `${id}.glb`);
|
||||
const recipePath = resolve(assetsDir, `${id}.recipe.json`);
|
||||
const glb = Buffer.from(payload.glbBase64, "base64");
|
||||
await writeFile(glbPath, glb);
|
||||
await writeFile(recipePath, `${JSON.stringify(payload.recipe, null, 2)}\n`);
|
||||
sendJson(res, 200, { ok: true, glb: glbPath, recipe: recipePath, bytes: glb.byteLength });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/api/texture") {
|
||||
const raw = await readBody(req);
|
||||
const payload = JSON.parse(raw.toString("utf8")) as {
|
||||
recipe?: unknown;
|
||||
mode?: "guide" | "texture";
|
||||
force?: boolean;
|
||||
albedoBase64?: string;
|
||||
};
|
||||
if (!payload.recipe) {
|
||||
sendJson(res, 400, { error: "Expected { recipe, mode }" });
|
||||
return;
|
||||
}
|
||||
const result = await runTextureCli({
|
||||
recipe: payload.recipe,
|
||||
mode: payload.mode === "texture" ? "texture" : "guide",
|
||||
force: payload.force === true,
|
||||
...(payload.albedoBase64 ? { albedoBase64: payload.albedoBase64 } : {}),
|
||||
});
|
||||
sendJson(res, result["ok"] ? 200 : 500, result);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendJson(res, 500, { error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root,
|
||||
plugins: [creatorApiPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@creator": toolsSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
open: false,
|
||||
fs: {
|
||||
allow: [workspaceRoot, dirname(toolsSrc)],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(root, "dist"),
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"three",
|
||||
"three/examples/jsm/loaders/GLTFLoader.js",
|
||||
"three/examples/jsm/controls/OrbitControls.js",
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user