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 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,17 +78,29 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
const configuration = await commands.updateSceneConfiguration({
|
if (skinFile) {
|
||||||
puppetScale,
|
const currentProfile = await commands.getProfile();
|
||||||
puppetOpacity,
|
const skinData = Array.from(
|
||||||
puppetMovementMode,
|
new Uint8Array(await skinFile.arrayBuffer()),
|
||||||
});
|
);
|
||||||
puppetScale = configuration.puppetScale;
|
await commands.updateProfile(currentProfile.displayName, skinData);
|
||||||
puppetOpacity = configuration.puppetOpacity;
|
clearSkinDraft();
|
||||||
puppetMovementMode = configuration.puppetMovementMode;
|
}
|
||||||
|
|
||||||
|
if (hasConfigurationChanges()) {
|
||||||
|
const configuration = await commands.updateSceneConfiguration({
|
||||||
|
puppetScale,
|
||||||
|
puppetOpacity,
|
||||||
|
puppetMovementMode,
|
||||||
|
});
|
||||||
|
puppetScale = configuration.puppetScale;
|
||||||
|
puppetOpacity = configuration.puppetOpacity;
|
||||||
|
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,52 +110,117 @@
|
|||||||
</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">
|
||||||
</div>
|
{#if $profile}
|
||||||
<input
|
<div
|
||||||
id="puppet-scale"
|
class="border border-primary relative shadow-[inset_0_0_10px] bg-primary/5 shadow-primary card"
|
||||||
class="range range-sm"
|
>
|
||||||
type="range"
|
<div
|
||||||
min="0.5"
|
class="size-full absolute bg-gridded opacity-25 inset-0"
|
||||||
max="2"
|
></div>
|
||||||
step="0.05"
|
<div class="size-full absolute">
|
||||||
bind:value={puppetScale}
|
<div
|
||||||
oninput={updateDirty}
|
class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1"
|
||||||
disabled={busy}
|
>
|
||||||
aria-describedby="puppet-scale-help"
|
<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">
|
<div class="min-w-0 space-y-2">
|
||||||
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
|
<div>
|
||||||
<output class="badge badge-outline tabular-nums" for="puppet-opacity">
|
<label class="fieldset-label" for="puppet-scale">Scale</label>
|
||||||
{Math.round(puppetOpacity * 100)}%
|
<input
|
||||||
</output>
|
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>
|
</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">
|
<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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user