very hot looking puppet preview and move skin btn to scene view
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user