minecraft characters.

This commit is contained in:
2026-08-28 19:30:47 +08:00
parent 9422bdc5c8
commit 9d695e704e
16 changed files with 964 additions and 52 deletions
@@ -0,0 +1,83 @@
import * as THREE from "three";
import type { PuppetRig } from ".";
export class PuppetAnimation {
private readonly smoothedGroundPosition = new THREE.Vector3();
private readonly movementDelta = new THREE.Vector3();
private hasSmoothedPosition = false;
constructor(private readonly rig: PuppetRig) {}
update(
targetGroundPosition: THREE.Vector3,
isMoving: boolean,
deltaSeconds: number,
elapsedSeconds: number,
) {
if (!this.hasSmoothedPosition) {
this.smoothedGroundPosition.copy(targetGroundPosition);
this.rig.root.position.copy(targetGroundPosition);
this.hasSmoothedPosition = true;
}
this.movementDelta
.copy(targetGroundPosition)
.sub(this.smoothedGroundPosition);
if (this.movementDelta.lengthSq() <= 0.01) {
this.pause();
this.rig.root.position.copy(targetGroundPosition);
return;
}
const alpha = 1 - Math.exp(-12 * deltaSeconds);
this.smoothedGroundPosition.lerp(targetGroundPosition, alpha);
this.rig.root.position.copy(this.smoothedGroundPosition);
const targetRotationY = Math.atan2(
this.movementDelta.x,
this.movementDelta.z,
);
this.rig.root.rotation.y = this.dampAngle(
this.rig.root.rotation.y,
targetRotationY,
14,
deltaSeconds,
);
if (isMoving) {
this.animateWalkCycle(elapsedSeconds);
} else {
this.pause();
}
}
pause() {
this.rig.leftLeg.rotation.x *= 0.8;
this.rig.rightLeg.rotation.x *= 0.8;
this.rig.leftArm.rotation.x *= 0.8;
this.rig.rightArm.rotation.x *= 0.8;
}
private dampAngle(
currentRadians: number,
targetRadians: number,
smoothingSpeed: number,
deltaSeconds: number,
) {
const deltaRadians = Math.atan2(
Math.sin(targetRadians - currentRadians),
Math.cos(targetRadians - currentRadians),
);
const alpha = 1 - Math.exp(-smoothingSpeed * deltaSeconds);
return currentRadians + deltaRadians * alpha;
}
private animateWalkCycle(elapsedSeconds: number) {
const swing = Math.sin(elapsedSeconds * 8) * 0.5;
this.rig.leftLeg.rotation.x = swing * 2;
this.rig.rightLeg.rotation.x = -swing * 2;
this.rig.leftArm.rotation.x = -swing * 2;
this.rig.rightArm.rotation.x = swing * 2;
}
}
@@ -0,0 +1,121 @@
import type { PuppetState } from "$lib/bindings";
import * as THREE from "three";
import type { World } from "../world";
import { PuppetAnimation } from "./animation";
export type PuppetRig = {
root: THREE.Group;
leftArm: THREE.Group;
rightArm: THREE.Group;
leftLeg: THREE.Group;
rightLeg: THREE.Group;
};
export class Puppet {
readonly root: THREE.Group;
private readonly rig: PuppetRig;
private readonly animation: PuppetAnimation;
private readonly targetGroundPosition = new THREE.Vector3();
constructor(readonly id: string) {
this.rig = this.createRig();
this.root = this.rig.root;
this.animation = new PuppetAnimation(this.rig);
}
update(
state: PuppetState,
world: World,
frozen: boolean,
deltaSeconds: number,
elapsedSeconds: number,
) {
if (frozen) {
this.animation.pause();
return;
}
world.normalizedPointToGroundPoint(
state.position.x,
state.position.y,
this.targetGroundPosition,
);
this.animation.update(
this.targetGroundPosition,
state.isMoving,
deltaSeconds,
elapsedSeconds,
);
}
dispose() {
const disposedMaterials = new Set<THREE.Material>();
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.geometry.dispose();
const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) {
if (disposedMaterials.has(material)) continue;
material.dispose();
disposedMaterials.add(material);
}
});
}
private createRig(): PuppetRig {
const root = new THREE.Group();
const material = new THREE.MeshStandardMaterial({
color: this.colorFromId(),
});
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 leftArm = this.createLimbPivot(-12, 48, 8, 24, 8, material);
const rightArm = this.createLimbPivot(12, 48, 8, 24, 8, material);
const leftLeg = this.createLimbPivot(-4, 24, 8, 24, 8, material);
const rightLeg = this.createLimbPivot(4, 24, 8, 24, 8, material);
root.add(body, head, leftArm, rightArm, leftLeg, rightLeg);
return { root, leftArm, rightArm, leftLeg, rightLeg };
}
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;
}
private colorFromId() {
let hash = 0;
for (const character of this.id) {
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
}
return new THREE.Color().setHSL((hash % 360) / 360, 0.55, 0.62);
}
}
@@ -0,0 +1,66 @@
import type { PuppetState } from "$lib/bindings";
import type { PuppetScreenBounds } from "../types";
import type { World } from "../world";
import { Puppet } from ".";
export class PuppetManager {
private readonly puppets = new Map<string, Puppet>();
constructor(private readonly world: World) {}
update(
states: readonly PuppetState[],
frozenPuppetId: string | null,
deltaSeconds: number,
elapsedSeconds: number,
) {
this.syncPuppets(states);
for (const state of states) {
this.puppets
.get(state.id)
?.update(
state,
this.world,
state.id === frozenPuppetId,
deltaSeconds,
elapsedSeconds,
);
}
}
screenBounds(): PuppetScreenBounds[] {
return [...this.puppets.values()].map((puppet) => ({
id: puppet.id,
...this.world.objectScreenBounds(puppet.root),
}));
}
dispose() {
for (const puppet of this.puppets.values()) {
this.world.removeObject(puppet.root);
puppet.dispose();
}
this.puppets.clear();
}
private syncPuppets(states: readonly PuppetState[]) {
const nextPuppetIds = new Set(states.map((state) => state.id));
for (const state of states) {
if (this.puppets.has(state.id)) continue;
const puppet = new Puppet(state.id);
this.puppets.set(puppet.id, puppet);
this.world.addObject(puppet.root);
}
for (const [puppetId, puppet] of this.puppets) {
if (nextPuppetIds.has(puppetId)) continue;
this.world.removeObject(puppet.root);
puppet.dispose();
this.puppets.delete(puppetId);
}
}
}