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 busy = $state(false);
let error = $state("");
let skinFile = $state<File | null>(null);
let skinInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (!dirty) displayName = $profile?.displayName ?? "";
@@ -23,15 +21,12 @@
onMount(() => register("general", { apply, reset }, { dirty, busy }));
function updateDirty() {
dirty =
displayName.trim() !== ($profile?.displayName ?? "") || skinFile !== null;
dirty = displayName.trim() !== ($profile?.displayName ?? "");
error = "";
}
function reset() {
displayName = $profile?.displayName ?? "";
skinFile = null;
if (skinInput) skinInput.value = "";
dirty = false;
error = "";
}
@@ -47,13 +42,8 @@
busy = true;
error = "";
try {
const skinData = skinFile
? Array.from(new Uint8Array(await skinFile.arrayBuffer()))
: null;
await commands.updateProfile(nextName, skinData);
await commands.updateProfile(nextName, null);
displayName = nextName;
skinFile = null;
if (skinInput) skinInput.value = "";
dirty = false;
return true;
} catch (cause) {
@@ -88,34 +78,6 @@
This name can be changed at any time.
</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"
>Identification Key
<div
@@ -4,7 +4,9 @@
sceneConfiguration,
sceneConfigurationListenerError,
} 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 type { RegisterPanel } from "./types";
@@ -13,6 +15,9 @@
let puppetScale = $state(1);
let puppetOpacity = $state(1);
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 busy = $state(false);
let error = $state("");
@@ -27,19 +32,42 @@
});
onMount(() => register("scene", { apply, reset }, { dirty, busy }));
onDestroy(() => {
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
});
function updateDirty() {
dirty =
function hasConfigurationChanges() {
return (
puppetScale !== $sceneConfiguration.puppetScale ||
puppetOpacity !== $sceneConfiguration.puppetOpacity ||
puppetMovementMode !== $sceneConfiguration.puppetMovementMode;
puppetMovementMode !== $sceneConfiguration.puppetMovementMode
);
}
function updateDirty() {
dirty = hasConfigurationChanges() || skinFile !== null;
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() {
puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode;
clearSkinDraft();
dirty = false;
error = "";
}
@@ -50,17 +78,29 @@
busy = true;
error = "";
try {
const configuration = await commands.updateSceneConfiguration({
puppetScale,
puppetOpacity,
puppetMovementMode,
});
puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity;
puppetMovementMode = configuration.puppetMovementMode;
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({
puppetScale,
puppetOpacity,
puppetMovementMode,
});
puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity;
puppetMovementMode = configuration.puppetMovementMode;
}
dirty = false;
return true;
} catch (cause) {
dirty = hasConfigurationChanges() || skinFile !== null;
error = String(cause);
return false;
} finally {
@@ -70,52 +110,117 @@
</script>
<div class="space-y-3">
{#if error || $sceneConfigurationListenerError}
{#if error || $sceneConfigurationListenerError || $profileListenerError}
<PanelMessage kind="error">
{error || $sceneConfigurationListenerError}
{error || $sceneConfigurationListenerError || $profileListenerError}
</PanelMessage>
{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Puppets</legend>
<div class="flex items-center justify-between gap-3">
<label class="fieldset-label" for="puppet-scale">Scale</label>
<output class="badge badge-outline tabular-nums" for="puppet-scale">
{puppetScale.toFixed(2)}×
</output>
</div>
<input
id="puppet-scale"
class="range range-sm"
type="range"
min="0.5"
max="2"
step="0.05"
bind:value={puppetScale}
oninput={updateDirty}
disabled={busy}
aria-describedby="puppet-scale-help"
/>
<div
class="grid grid-cols-[minmax(0,3fr)_minmax(0,4fr)] items-center gap-3"
>
<div class="flex flex-col gap-2">
<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 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="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 class="min-w-0 space-y-2">
<div>
<label class="fieldset-label" for="puppet-scale">Scale</label>
<input
id="puppet-scale"
class="range range-sm"
type="range"
min="0.5"
max="2"
step="0.05"
bind:value={puppetScale}
oninput={updateDirty}
disabled={busy}
aria-describedby="puppet-scale-help"
/>
</div>
<div>
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<input
id="puppet-opacity"
class="range range-sm"
type="range"
min="0.25"
max="1"
step="0.05"
bind:value={puppetOpacity}
oninput={updateDirty}
disabled={busy}
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>
<input
id="puppet-opacity"
class="range range-sm"
type="range"
min="0.25"
max="1"
step="0.05"
bind:value={puppetOpacity}
oninput={updateDirty}
disabled={busy}
aria-describedby="puppet-opacity-help"
/>
<fieldset class="mt-4">
<legend class="fieldset-label mb-2">Movement</legend>
@@ -185,3 +290,34 @@
</fieldset>
</fieldset>
</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 type { PuppetRig } from ".";
import type { PuppetRig } from "./visual";
const MIN_IDLE_FACING_OFFSET_DEGREES = 3;
const MAX_IDLE_FACING_OFFSET_DEGREES = 5;
@@ -67,6 +67,10 @@ export class PuppetAnimation {
this.rig.rightArm.rotation.x *= 0.8;
}
walk(elapsedSeconds: number) {
this.animateWalkCycle(elapsedSeconds);
}
private updateIdleTarget(isMoving: boolean) {
if (isMoving) {
this.wasMoving = true;
@@ -1,52 +1,26 @@
import type { PuppetState } from "$lib/bindings";
import * as THREE from "three";
import type { World } from "../world";
import { PuppetAnimation } from "./animation";
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[]
>;
};
import { PuppetVisual } from "./visual";
export class Puppet {
readonly root: THREE.Group;
private readonly rig: PuppetRig;
private readonly animation: PuppetAnimation;
private readonly skin: PuppetSkin;
private readonly visual: PuppetVisual;
private readonly targetGroundPosition = new THREE.Vector3();
constructor(readonly id: string, scale: number) {
this.rig = this.createRig();
this.root = this.rig.root;
this.animation = new PuppetAnimation(this.rig);
const placeholderMaterial = this.rig.body.material;
this.skin = new PuppetSkin(id, this.rig);
this.setScale(scale);
if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose();
constructor(
readonly id: string,
scale: number,
) {
this.visual = new PuppetVisual(id);
this.root = this.visual.root;
this.visual.setScale(scale);
this.visual.setOpacity(1);
}
setScale(scale: number) {
if (this.root.scale.x === scale) return;
this.root.scale.setScalar(scale);
this.visual.setScale(scale);
}
update(
@@ -58,10 +32,10 @@ export class Puppet {
skinHash: string | null,
opacity: number,
) {
this.skin.update(skinHash);
this.skin.setOpacity(opacity);
this.visual.setSkin(skinHash);
this.visual.setOpacity(opacity);
if (frozen) {
this.animation.pause();
this.visual.pause();
return;
}
@@ -70,7 +44,7 @@ export class Puppet {
state.position.y,
this.targetGroundPosition,
);
this.animation.update(
this.visual.updateMotion(
this.targetGroundPosition,
state.isMoving,
deltaSeconds,
@@ -79,101 +53,6 @@ export class Puppet {
}
dispose() {
this.skin.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 };
this.visual.dispose();
}
}
@@ -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 * as THREE from "three";
import type { PuppetRig } from ".";
import type { PuppetRig } from "./visual";
type Region = {
x: number;
@@ -120,7 +120,7 @@ export class PuppetSkin {
private readonly material: THREE.MeshStandardMaterial;
private readonly depthMaterial: THREE.MeshBasicMaterial;
private texture: THREE.Texture | null = null;
private requestedHash: string | null | undefined;
private requestedSkinKey: string | undefined;
private requestId = 0;
constructor(
@@ -160,11 +160,21 @@ export class PuppetSkin {
}
update(skinHash: string | null) {
if (skinHash === this.requestedHash) return;
this.requestedHash = skinHash;
this.updateTexture(`hash:${skinHash ?? "default"}`, () =>
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;
resolveSkinSource(this.userId, skinHash)
resolveSource()
.then(async (source) => {
if (requestId !== this.requestId) return;
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 };
}