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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user