very hot looking puppet preview and move skin btn to scene view

This commit is contained in:
2026-08-30 01:48:48 +08:00
parent 64e5c363f2
commit 902093d75f
7 changed files with 508 additions and 232 deletions
@@ -12,8 +12,6 @@
let dirty = $state(false); let dirty = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(""); let error = $state("");
let skinFile = $state<File | null>(null);
let skinInput = $state<HTMLInputElement | null>(null);
$effect(() => { $effect(() => {
if (!dirty) displayName = $profile?.displayName ?? ""; if (!dirty) displayName = $profile?.displayName ?? "";
@@ -23,15 +21,12 @@
onMount(() => register("general", { apply, reset }, { dirty, busy })); onMount(() => register("general", { apply, reset }, { dirty, busy }));
function updateDirty() { function updateDirty() {
dirty = dirty = displayName.trim() !== ($profile?.displayName ?? "");
displayName.trim() !== ($profile?.displayName ?? "") || skinFile !== null;
error = ""; error = "";
} }
function reset() { function reset() {
displayName = $profile?.displayName ?? ""; displayName = $profile?.displayName ?? "";
skinFile = null;
if (skinInput) skinInput.value = "";
dirty = false; dirty = false;
error = ""; error = "";
} }
@@ -47,13 +42,8 @@
busy = true; busy = true;
error = ""; error = "";
try { try {
const skinData = skinFile await commands.updateProfile(nextName, null);
? Array.from(new Uint8Array(await skinFile.arrayBuffer()))
: null;
await commands.updateProfile(nextName, skinData);
displayName = nextName; displayName = nextName;
skinFile = null;
if (skinInput) skinInput.value = "";
dirty = false; dirty = false;
return true; return true;
} catch (cause) { } catch (cause) {
@@ -88,34 +78,6 @@
This name can be changed at any time. This name can be changed at any time.
</p> </p>
<label class="fieldset-label mt-2" for="skin-file">Character skin</label>
<div class="join w-full">
<input
class="input join-item min-w-0 flex-1"
value={skinFile?.name ?? ($profile?.skinHash ? "Custom skin" : "Default skin")}
readonly
aria-label="Selected character skin"
/>
<button
class="btn join-item"
type="button"
disabled={!$profile || busy}
onclick={() => skinInput?.click()}>Choose PNG</button
>
</div>
<input
id="skin-file"
class="hidden"
type="file"
accept="image/png"
bind:this={skinInput}
onchange={(event) => {
skinFile = event.currentTarget.files?.[0] ?? null;
updateDirty();
}}
/>
<p class="fieldset-label">64×64 Minecraft-format PNG, base layer only.</p>
<label class="fieldset-label mt-2" for="public-key" <label class="fieldset-label mt-2" for="public-key"
>Identification Key >Identification Key
<div <div
@@ -4,7 +4,9 @@
sceneConfiguration, sceneConfiguration,
sceneConfigurationListenerError, sceneConfigurationListenerError,
} from "$lib/listeners/scene-configuration"; } from "$lib/listeners/scene-configuration";
import { onMount } from "svelte"; import { profile, profileListenerError } from "$lib/listeners/profile";
import { onDestroy, onMount } from "svelte";
import PuppetPreview from "../../../routes/scene/components/renderer/puppet/preview.svelte";
import PanelMessage from "./panel-message.svelte"; import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types"; import type { RegisterPanel } from "./types";
@@ -13,6 +15,9 @@
let puppetScale = $state(1); let puppetScale = $state(1);
let puppetOpacity = $state(1); let puppetOpacity = $state(1);
let puppetMovementMode = $state<PuppetMovementMode>("free"); let puppetMovementMode = $state<PuppetMovementMode>("free");
let skinFile = $state<File | null>(null);
let skinPreviewUrl = $state<string | null>(null);
let skinInput = $state<HTMLInputElement | null>(null);
let dirty = $state(false); let dirty = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(""); let error = $state("");
@@ -27,19 +32,42 @@
}); });
onMount(() => register("scene", { apply, reset }, { dirty, busy })); onMount(() => register("scene", { apply, reset }, { dirty, busy }));
onDestroy(() => {
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
});
function updateDirty() { function hasConfigurationChanges() {
dirty = return (
puppetScale !== $sceneConfiguration.puppetScale || puppetScale !== $sceneConfiguration.puppetScale ||
puppetOpacity !== $sceneConfiguration.puppetOpacity || puppetOpacity !== $sceneConfiguration.puppetOpacity ||
puppetMovementMode !== $sceneConfiguration.puppetMovementMode; puppetMovementMode !== $sceneConfiguration.puppetMovementMode
);
}
function updateDirty() {
dirty = hasConfigurationChanges() || skinFile !== null;
error = ""; error = "";
} }
function clearSkinDraft() {
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
skinFile = null;
skinPreviewUrl = null;
if (skinInput) skinInput.value = "";
}
function selectSkinFile(file: File | null) {
clearSkinDraft();
skinFile = file;
skinPreviewUrl = file ? URL.createObjectURL(file) : null;
updateDirty();
}
function reset() { function reset() {
puppetScale = $sceneConfiguration.puppetScale; puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity; puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode; puppetMovementMode = $sceneConfiguration.puppetMovementMode;
clearSkinDraft();
dirty = false; dirty = false;
error = ""; error = "";
} }
@@ -50,6 +78,16 @@
busy = true; busy = true;
error = ""; error = "";
try { try {
if (skinFile) {
const currentProfile = await commands.getProfile();
const skinData = Array.from(
new Uint8Array(await skinFile.arrayBuffer()),
);
await commands.updateProfile(currentProfile.displayName, skinData);
clearSkinDraft();
}
if (hasConfigurationChanges()) {
const configuration = await commands.updateSceneConfiguration({ const configuration = await commands.updateSceneConfiguration({
puppetScale, puppetScale,
puppetOpacity, puppetOpacity,
@@ -58,9 +96,11 @@
puppetScale = configuration.puppetScale; puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity; puppetOpacity = configuration.puppetOpacity;
puppetMovementMode = configuration.puppetMovementMode; puppetMovementMode = configuration.puppetMovementMode;
}
dirty = false; dirty = false;
return true; return true;
} catch (cause) { } catch (cause) {
dirty = hasConfigurationChanges() || skinFile !== null;
error = String(cause); error = String(cause);
return false; return false;
} finally { } finally {
@@ -70,21 +110,63 @@
</script> </script>
<div class="space-y-3"> <div class="space-y-3">
{#if error || $sceneConfigurationListenerError} {#if error || $sceneConfigurationListenerError || $profileListenerError}
<PanelMessage kind="error"> <PanelMessage kind="error">
{error || $sceneConfigurationListenerError} {error || $sceneConfigurationListenerError || $profileListenerError}
</PanelMessage> </PanelMessage>
{/if} {/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3"> <fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Puppets</legend> <legend class="fieldset-legend px-1">Puppets</legend>
<div class="flex items-center justify-between gap-3"> <div
<label class="fieldset-label" for="puppet-scale">Scale</label> class="grid grid-cols-[minmax(0,3fr)_minmax(0,4fr)] items-center gap-3"
<output class="badge badge-outline tabular-nums" for="puppet-scale"> >
{puppetScale.toFixed(2)}× <div class="flex flex-col gap-2">
</output> <div class="aspect-square min-w-0">
{#if $profile}
<div
class="border border-primary relative shadow-[inset_0_0_10px] bg-primary/5 shadow-primary card"
>
<div
class="size-full absolute bg-gridded opacity-25 inset-0"
></div>
<div class="size-full absolute">
<div
class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1"
>
<div class="text-start flex flex-col">
<p>Scale</p>
<p>Opacity</p>
</div> </div>
<div class="text-end flex flex-col">
<p>{(puppetScale * 100).toFixed(0)}%</p>
<p>{(puppetOpacity * 100).toFixed(0)}%</p>
</div>
</div>
</div>
<div class="size-full z-10">
<PuppetPreview
userId={$profile.id}
skinHash={$profile.skinHash}
skinSource={skinPreviewUrl}
scale={puppetScale}
opacity={puppetOpacity}
/>
</div>
</div>
{:else}
<div
class="skeleton aspect-square size-full"
aria-label="Loading puppet preview"
></div>
{/if}
</div>
</div>
<div class="min-w-0 space-y-2">
<div>
<label class="fieldset-label" for="puppet-scale">Scale</label>
<input <input
id="puppet-scale" id="puppet-scale"
class="range range-sm" class="range range-sm"
@@ -97,13 +179,10 @@
disabled={busy} disabled={busy}
aria-describedby="puppet-scale-help" aria-describedby="puppet-scale-help"
/> />
<div class="mt-3 flex items-center justify-between gap-3">
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<output class="badge badge-outline tabular-nums" for="puppet-opacity">
{Math.round(puppetOpacity * 100)}%
</output>
</div> </div>
<div>
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<input <input
id="puppet-opacity" id="puppet-opacity"
class="range range-sm" class="range range-sm"
@@ -116,6 +195,32 @@
disabled={busy} disabled={busy}
aria-describedby="puppet-opacity-help" aria-describedby="puppet-opacity-help"
/> />
</div>
<div>
<label class="fieldset-label" for="puppet-skin">Custom Skin</label>
<div>
<button
class="btn w-full"
type="button"
disabled={!$profile || busy}
onclick={() => skinInput?.click()}
>
Choose file
</button>
<input
id="skin-file"
class="hidden"
type="file"
accept="image/png"
bind:this={skinInput}
onchange={(event) =>
selectSkinFile(event.currentTarget.files?.[0] ?? null)}
/>
</div>
</div>
</div>
</div>
<fieldset class="mt-4"> <fieldset class="mt-4">
<legend class="fieldset-label mb-2">Movement</legend> <legend class="fieldset-label mb-2">Movement</legend>
@@ -185,3 +290,34 @@
</fieldset> </fieldset>
</fieldset> </fieldset>
</div> </div>
<style>
.bg-gridded {
background-image:
linear-gradient(
0deg,
transparent 24%,
var(--color-primary) 25%,
var(--color-primary) 26%,
transparent 27%,
transparent 74%,
var(--color-primary) 75%,
var(--color-primary) 76%,
transparent 77%,
transparent
),
linear-gradient(
90deg,
transparent 24%,
var(--color-primary) 25%,
var(--color-primary) 26%,
transparent 27%,
transparent 74%,
var(--color-primary) 75%,
var(--color-primary) 76%,
transparent 77%,
transparent
);
background-size: 32px 32px;
}
</style>
@@ -1,5 +1,5 @@
import * as THREE from "three"; import * as THREE from "three";
import type { PuppetRig } from "."; import type { PuppetRig } from "./visual";
const MIN_IDLE_FACING_OFFSET_DEGREES = 3; const MIN_IDLE_FACING_OFFSET_DEGREES = 3;
const MAX_IDLE_FACING_OFFSET_DEGREES = 5; const MAX_IDLE_FACING_OFFSET_DEGREES = 5;
@@ -67,6 +67,10 @@ export class PuppetAnimation {
this.rig.rightArm.rotation.x *= 0.8; this.rig.rightArm.rotation.x *= 0.8;
} }
walk(elapsedSeconds: number) {
this.animateWalkCycle(elapsedSeconds);
}
private updateIdleTarget(isMoving: boolean) { private updateIdleTarget(isMoving: boolean) {
if (isMoving) { if (isMoving) {
this.wasMoving = true; this.wasMoving = true;
@@ -1,52 +1,26 @@
import type { PuppetState } from "$lib/bindings"; import type { PuppetState } from "$lib/bindings";
import * as THREE from "three"; import * as THREE from "three";
import type { World } from "../world"; import type { World } from "../world";
import { PuppetAnimation } from "./animation"; import { PuppetVisual } from "./visual";
import { PuppetSkin } from "./skin";
const CAMERA_FACING_ROTATION_Y = Math.PI / 4;
export type PuppetRig = {
root: THREE.Group;
body: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
head: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
leftArm: THREE.Group;
leftArmMesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
rightArm: THREE.Group;
rightArmMesh: THREE.Mesh<
THREE.BoxGeometry,
THREE.Material | THREE.Material[]
>;
leftLeg: THREE.Group;
leftLegMesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
rightLeg: THREE.Group;
rightLegMesh: THREE.Mesh<
THREE.BoxGeometry,
THREE.Material | THREE.Material[]
>;
};
export class Puppet { export class Puppet {
readonly root: THREE.Group; readonly root: THREE.Group;
private readonly rig: PuppetRig; private readonly visual: PuppetVisual;
private readonly animation: PuppetAnimation;
private readonly skin: PuppetSkin;
private readonly targetGroundPosition = new THREE.Vector3(); private readonly targetGroundPosition = new THREE.Vector3();
constructor(readonly id: string, scale: number) { constructor(
this.rig = this.createRig(); readonly id: string,
this.root = this.rig.root; scale: number,
this.animation = new PuppetAnimation(this.rig); ) {
const placeholderMaterial = this.rig.body.material; this.visual = new PuppetVisual(id);
this.skin = new PuppetSkin(id, this.rig); this.root = this.visual.root;
this.setScale(scale); this.visual.setScale(scale);
if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose(); this.visual.setOpacity(1);
} }
setScale(scale: number) { setScale(scale: number) {
if (this.root.scale.x === scale) return; this.visual.setScale(scale);
this.root.scale.setScalar(scale);
} }
update( update(
@@ -58,10 +32,10 @@ export class Puppet {
skinHash: string | null, skinHash: string | null,
opacity: number, opacity: number,
) { ) {
this.skin.update(skinHash); this.visual.setSkin(skinHash);
this.skin.setOpacity(opacity); this.visual.setOpacity(opacity);
if (frozen) { if (frozen) {
this.animation.pause(); this.visual.pause();
return; return;
} }
@@ -70,7 +44,7 @@ export class Puppet {
state.position.y, state.position.y,
this.targetGroundPosition, this.targetGroundPosition,
); );
this.animation.update( this.visual.updateMotion(
this.targetGroundPosition, this.targetGroundPosition,
state.isMoving, state.isMoving,
deltaSeconds, deltaSeconds,
@@ -79,101 +53,6 @@ export class Puppet {
} }
dispose() { dispose() {
this.skin.dispose(); this.visual.dispose();
const geometries = new Set<THREE.BufferGeometry>();
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
geometries.add(object.geometry);
});
for (const geometry of geometries) geometry.dispose();
}
private createRig(): PuppetRig {
const root = new THREE.Group();
root.rotation.y = CAMERA_FACING_ROTATION_Y;
const material = new THREE.MeshStandardMaterial();
const body = new THREE.Mesh(
new THREE.BoxGeometry(16, 24, 8),
material,
);
body.position.y = 36;
const head = new THREE.Mesh(
new THREE.BoxGeometry(16, 16, 16),
material,
);
head.position.y = 56;
const { pivot: leftArm, mesh: leftArmMesh } = this.createLimbPivot(
-12,
48,
8,
24,
8,
material,
);
const { pivot: rightArm, mesh: rightArmMesh } = this.createLimbPivot(
12,
48,
8,
24,
8,
material,
);
const { pivot: leftLeg, mesh: leftLegMesh } = this.createLimbPivot(
-4,
24,
8,
24,
8,
material,
);
const { pivot: rightLeg, mesh: rightLegMesh } = this.createLimbPivot(
4,
24,
8,
24,
8,
material,
);
root.add(body, head, leftArm, rightArm, leftLeg, rightLeg);
return {
root,
body,
head,
leftArm,
leftArmMesh,
rightArm,
rightArmMesh,
leftLeg,
leftLegMesh,
rightLeg,
rightLegMesh,
};
}
private createLimbPivot(
x: number,
y: number,
width: number,
height: number,
depth: number,
material: THREE.Material,
) {
const pivot = new THREE.Group();
pivot.position.set(x, y, 0);
const limb = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
material,
);
limb.position.y = -height / 2;
pivot.add(limb);
return { pivot, mesh: limb };
} }
} }
@@ -0,0 +1,97 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import * as THREE from "three";
import { PuppetVisual, SOUTH_WEST_ROTATION_Y } from "./visual";
let {
userId,
skinHash,
skinSource = null,
scale,
opacity,
}: {
userId: string;
skinHash: string | null;
skinSource?: string | null;
scale: number;
opacity: number;
} = $props();
let container = $state<HTMLDivElement | null>(null);
let renderer: THREE.WebGLRenderer | null = null;
let visual: PuppetVisual | null = null;
let clock: THREE.Timer | null = null;
let resizeObserver: ResizeObserver | null = null;
let animationFrameId = 0;
function animate() {
if (!renderer || !visual || !clock) return;
animationFrameId = requestAnimationFrame(animate);
clock.update();
visual.setAppearance({ skinHash, skinSource, scale, opacity });
visual.walkInPlace(SOUTH_WEST_ROTATION_Y, clock.getElapsed());
renderer.render(scene, camera);
}
function resizePreview() {
if (!container || !renderer) return;
const { clientWidth: width, clientHeight: height } = container;
if (width === 0 || height === 0) return;
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
camera.left = width / -2;
camera.right = width / 2;
camera.top = height / 2;
camera.bottom = height / -2;
camera.updateProjectionMatrix();
}
const scene = new THREE.Scene();
scene.background = null;
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1_000);
camera.position.set(200, 134, 200);
camera.lookAt(0, 34, 0);
camera.updateProjectionMatrix();
scene.add(new THREE.AmbientLight("white", 0.8));
const directionalLight = new THREE.DirectionalLight("white", 2);
directionalLight.position.set(200, 150, 150);
scene.add(directionalLight);
onMount(() => {
if (!container) return;
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(1, 1);
container.appendChild(renderer.domElement);
resizePreview();
resizeObserver = new ResizeObserver(resizePreview);
resizeObserver.observe(container);
visual = new PuppetVisual(userId);
scene.add(visual.root);
clock = new THREE.Timer();
clock.connect(document);
animate();
});
onDestroy(() => {
cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
clock?.dispose();
visual?.dispose();
renderer?.dispose();
renderer?.domElement.remove();
});
</script>
<div
class="aspect-square size-full overflow-hidden"
aria-label="Live puppet preview"
role="img"
bind:this={container}
></div>
@@ -1,6 +1,6 @@
import { resolveSkinSource } from "$lib/skins"; import { resolveSkinSource } from "$lib/skins";
import * as THREE from "three"; import * as THREE from "three";
import type { PuppetRig } from "."; import type { PuppetRig } from "./visual";
type Region = { type Region = {
x: number; x: number;
@@ -120,7 +120,7 @@ export class PuppetSkin {
private readonly material: THREE.MeshStandardMaterial; private readonly material: THREE.MeshStandardMaterial;
private readonly depthMaterial: THREE.MeshBasicMaterial; private readonly depthMaterial: THREE.MeshBasicMaterial;
private texture: THREE.Texture | null = null; private texture: THREE.Texture | null = null;
private requestedHash: string | null | undefined; private requestedSkinKey: string | undefined;
private requestId = 0; private requestId = 0;
constructor( constructor(
@@ -160,11 +160,21 @@ export class PuppetSkin {
} }
update(skinHash: string | null) { update(skinHash: string | null) {
if (skinHash === this.requestedHash) return; this.updateTexture(`hash:${skinHash ?? "default"}`, () =>
this.requestedHash = skinHash; resolveSkinSource(this.userId, skinHash),
);
}
updateSource(source: string) {
this.updateTexture(`source:${source}`, () => Promise.resolve(source));
}
private updateTexture(key: string, resolveSource: () => Promise<string>) {
if (key === this.requestedSkinKey) return;
this.requestedSkinKey = key;
const requestId = ++this.requestId; const requestId = ++this.requestId;
resolveSkinSource(this.userId, skinHash) resolveSource()
.then(async (source) => { .then(async (source) => {
if (requestId !== this.requestId) return; if (requestId !== this.requestId) return;
const texture = await loadTexture(source); const texture = await loadTexture(source);
@@ -0,0 +1,188 @@
import * as THREE from "three";
import { PuppetAnimation } from "./animation";
import { PuppetSkin } from "./skin";
export const CAMERA_FACING_ROTATION_Y = Math.PI / 4;
export const SOUTH_WEST_ROTATION_Y = -Math.PI / 0.01;
export type PuppetAppearance = {
skinHash: string | null;
skinSource?: string | null;
scale: number;
opacity: number;
};
export type PuppetRig = {
root: THREE.Group;
body: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
head: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
leftArm: THREE.Group;
leftArmMesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
rightArm: THREE.Group;
rightArmMesh: THREE.Mesh<
THREE.BoxGeometry,
THREE.Material | THREE.Material[]
>;
leftLeg: THREE.Group;
leftLegMesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
rightLeg: THREE.Group;
rightLegMesh: THREE.Mesh<
THREE.BoxGeometry,
THREE.Material | THREE.Material[]
>;
};
export class PuppetVisual {
readonly root: THREE.Group;
private readonly rig: PuppetRig;
private readonly animation: PuppetAnimation;
private readonly skin: PuppetSkin;
constructor(userId: string) {
this.rig = createRig();
this.root = this.rig.root;
this.animation = new PuppetAnimation(this.rig);
const placeholderMaterial = this.rig.body.material;
this.skin = new PuppetSkin(userId, this.rig);
if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose();
}
setAppearance({ skinHash, skinSource, scale, opacity }: PuppetAppearance) {
if (skinSource) this.setSkinSource(skinSource);
else this.setSkin(skinHash);
this.setScale(scale);
this.setOpacity(opacity);
}
setSkin(skinHash: string | null) {
this.skin.update(skinHash);
}
setSkinSource(source: string) {
this.skin.updateSource(source);
}
setScale(scale: number) {
if (this.root.scale.x !== scale) this.root.scale.setScalar(scale);
}
setOpacity(opacity: number) {
this.skin.setOpacity(opacity);
}
updateMotion(
targetGroundPosition: THREE.Vector3,
isMoving: boolean,
deltaSeconds: number,
elapsedSeconds: number,
) {
this.animation.update(
targetGroundPosition,
isMoving,
deltaSeconds,
elapsedSeconds,
);
}
walkInPlace(facingRotationY: number, elapsedSeconds: number) {
this.root.rotation.y = facingRotationY;
this.animation.walk(elapsedSeconds);
}
pause() {
this.animation.pause();
}
dispose() {
this.skin.dispose();
const geometries = new Set<THREE.BufferGeometry>();
this.root.traverse((object) => {
if (object instanceof THREE.Mesh) geometries.add(object.geometry);
});
for (const geometry of geometries) geometry.dispose();
}
}
function createRig(): PuppetRig {
const root = new THREE.Group();
root.rotation.y = CAMERA_FACING_ROTATION_Y;
const material = new THREE.MeshStandardMaterial();
const body = new THREE.Mesh(new THREE.BoxGeometry(16, 24, 8), material);
body.position.y = 36;
const head = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), material);
head.position.y = 56;
const { pivot: leftArm, mesh: leftArmMesh } = createLimbPivot(
-12,
48,
8,
24,
8,
material,
);
const { pivot: rightArm, mesh: rightArmMesh } = createLimbPivot(
12,
48,
8,
24,
8,
material,
);
const { pivot: leftLeg, mesh: leftLegMesh } = createLimbPivot(
-4,
24,
8,
24,
8,
material,
);
const { pivot: rightLeg, mesh: rightLegMesh } = createLimbPivot(
4,
24,
8,
24,
8,
material,
);
root.add(body, head, leftArm, rightArm, leftLeg, rightLeg);
return {
root,
body,
head,
leftArm,
leftArmMesh,
rightArm,
rightArmMesh,
leftLeg,
leftLegMesh,
rightLeg,
rightLegMesh,
};
}
function createLimbPivot(
x: number,
y: number,
width: number,
height: number,
depth: number,
material: THREE.Material,
) {
const pivot = new THREE.Group();
pivot.position.set(x, y, 0);
const limb = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
material,
);
limb.position.y = -height / 2;
pivot.add(limb);
return { pivot, mesh: limb };
}