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:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+267
View File
@@ -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 };
+769
View File
@@ -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();
+284
View File
@@ -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);
};
}
+542
View File
@@ -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;
}
}