minecraft skins!

This commit is contained in:
2026-08-29 01:41:59 +08:00
parent 9d695e704e
commit b6401a79a3
29 changed files with 1466 additions and 383 deletions
+11
View File
@@ -27,6 +27,16 @@
id === $liveMetadata.localId || $onlineFriendIds.has(id),
),
);
let skinHashes = $derived(
new Map(
visiblePuppets.map(({ id }) => [
id,
id === $profile?.id
? $profile.skinHash
: ($friends.find((friend) => friend.id === id)?.skinHash ?? null),
]),
),
);
onMount(startHitboxSync);
@@ -94,6 +104,7 @@
puppets={visiblePuppets}
frozenPuppetId={selectedUserId}
onBoundsChange={(bounds) => (puppetBounds = bounds)}
{skinHashes}
/>
<div role="banner" class="pointer-events-none fixed inset-0 z-10">
@@ -10,10 +10,12 @@
puppets,
frozenPuppetId,
onBoundsChange,
skinHashes,
}: {
puppets: readonly PuppetState[];
frozenPuppetId: string | null;
onBoundsChange: (bounds: PuppetScreenBounds[]) => void;
skinHashes: ReadonlyMap<string, string | null>;
} = $props();
let renderDiv = $state<HTMLDivElement | null>(null);
@@ -37,6 +39,7 @@
frozenPuppetId,
clock.getDelta(),
clock.getElapsed(),
skinHashes,
);
world.render();
@@ -2,13 +2,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";
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 {
@@ -16,12 +29,16 @@ export class Puppet {
private readonly rig: PuppetRig;
private readonly animation: PuppetAnimation;
private readonly skin: PuppetSkin;
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);
const placeholderMaterial = this.rig.body.material;
this.skin = new PuppetSkin(id, this.rig);
if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose();
}
update(
@@ -30,7 +47,9 @@ export class Puppet {
frozen: boolean,
deltaSeconds: number,
elapsedSeconds: number,
skinHash: string | null,
) {
this.skin.update(skinHash);
if (frozen) {
this.animation.pause();
return;
@@ -50,28 +69,18 @@ export class Puppet {
}
dispose() {
const disposedMaterials = new Set<THREE.Material>();
this.skin.dispose();
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 material = new THREE.MeshStandardMaterial();
const body = new THREE.Mesh(new THREE.BoxGeometry(16, 24, 8), material);
body.position.y = 36;
@@ -79,14 +88,54 @@ export class Puppet {
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);
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, leftArm, rightArm, leftLeg, rightLeg };
return {
root,
body,
head,
leftArm,
leftArmMesh,
rightArm,
rightArmMesh,
leftLeg,
leftLegMesh,
rightLeg,
rightLegMesh,
};
}
private createLimbPivot(
@@ -107,15 +156,6 @@ export class Puppet {
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);
return { pivot, mesh: limb };
}
}
@@ -13,6 +13,7 @@ export class PuppetManager {
frozenPuppetId: string | null,
deltaSeconds: number,
elapsedSeconds: number,
skinHashes: ReadonlyMap<string, string | null>,
) {
this.syncPuppets(states);
@@ -25,6 +26,7 @@ export class PuppetManager {
state.id === frozenPuppetId,
deltaSeconds,
elapsedSeconds,
skinHashes.get(state.id) ?? null,
);
}
}
@@ -0,0 +1,176 @@
import { resolveSkinSource } from "$lib/skins";
import * as THREE from "three";
import type { PuppetRig } from ".";
type Region = {
x: number;
y: number;
width: number;
height: number;
flipX?: boolean;
};
type Faces = [Region, Region, Region, Region, Region, Region];
type SkinPart = {
mesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material | THREE.Material[]>;
faces: Faces;
};
const ATLAS_SIZE = 64;
const region = (
x: number,
y: number,
width: number,
height: number,
flipX = false,
): Region => ({ x, y, width, height, flipX });
const faces = (
right: Region,
left: Region,
top: Region,
bottom: Region,
front: Region,
back: Region,
): Faces => [right, left, top, bottom, front, back];
const HEAD = faces(
region(0, 8, 8, 8, true),
region(16, 8, 8, 8, true),
region(8, 0, 8, 8),
region(16, 0, 8, 8),
region(8, 8, 8, 8),
region(24, 8, 8, 8),
);
const BODY = faces(
region(16, 20, 4, 12, true),
region(28, 20, 4, 12, true),
region(20, 16, 8, 4),
region(28, 16, 8, 4),
region(20, 20, 8, 12),
region(32, 20, 8, 12),
);
const RIGHT_ARM = faces(
region(40, 20, 4, 12, true),
region(48, 20, 4, 12, true),
region(44, 16, 4, 4),
region(48, 16, 4, 4),
region(44, 20, 4, 12),
region(52, 20, 4, 12),
);
const LEFT_ARM = faces(
region(32, 52, 4, 12, true),
region(40, 52, 4, 12, true),
region(36, 48, 4, 4),
region(40, 48, 4, 4),
region(36, 52, 4, 12),
region(44, 52, 4, 12),
);
const RIGHT_LEG = faces(
region(0, 20, 4, 12, true),
region(8, 20, 4, 12, true),
region(4, 16, 4, 4),
region(8, 16, 4, 4),
region(4, 20, 4, 12),
region(12, 20, 4, 12),
);
const LEFT_LEG = faces(
region(16, 52, 4, 12, true),
region(24, 52, 4, 12, true),
region(20, 48, 4, 4),
region(24, 48, 4, 4),
region(20, 52, 4, 12),
region(28, 52, 4, 12),
);
function applyUvs(geometry: THREE.BoxGeometry, regions: Faces) {
const uv = geometry.attributes.uv;
regions.forEach((item, faceIndex) => {
const offset = faceIndex * 4;
const regionLeft = item.x / ATLAS_SIZE;
const regionRight = (item.x + item.width) / ATLAS_SIZE;
const left = item.flipX ? regionRight : regionLeft;
const right = item.flipX ? regionLeft : regionRight;
const top = 1 - item.y / ATLAS_SIZE;
const bottom = 1 - (item.y + item.height) / ATLAS_SIZE;
uv.setXY(offset, left, top);
uv.setXY(offset + 1, right, top);
uv.setXY(offset + 2, left, bottom);
uv.setXY(offset + 3, right, bottom);
});
uv.needsUpdate = true;
}
function loadTexture(url: string) {
return new Promise<THREE.Texture>((resolve, reject) => {
new THREE.TextureLoader().load(
url,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.generateMipmaps = false;
resolve(texture);
},
undefined,
reject,
);
});
}
export class PuppetSkin {
private readonly material: THREE.MeshStandardMaterial;
private texture: THREE.Texture | null = null;
private requestedHash: string | null | undefined;
private requestId = 0;
constructor(
private readonly userId: string,
rig: PuppetRig,
) {
this.material = new THREE.MeshStandardMaterial({
color: "white",
transparent: true,
alphaTest: 0.5,
});
const parts: SkinPart[] = [
{ mesh: rig.head, faces: HEAD },
{ mesh: rig.body, faces: BODY },
{ mesh: rig.leftArmMesh, faces: LEFT_ARM },
{ mesh: rig.rightArmMesh, faces: RIGHT_ARM },
{ mesh: rig.leftLegMesh, faces: LEFT_LEG },
{ mesh: rig.rightLegMesh, faces: RIGHT_LEG },
];
for (const part of parts) {
part.mesh.material = this.material;
applyUvs(part.mesh.geometry, part.faces);
}
}
update(skinHash: string | null) {
if (skinHash === this.requestedHash) return;
this.requestedHash = skinHash;
const requestId = ++this.requestId;
resolveSkinSource(this.userId, skinHash)
.then(async (source) => {
if (requestId !== this.requestId) return;
const texture = await loadTexture(source);
if (requestId !== this.requestId) {
texture.dispose();
return;
}
this.texture?.dispose();
this.texture = texture;
this.material.map = texture;
this.material.needsUpdate = true;
})
.catch((error) =>
console.error(`Failed to load skin for ${this.userId}`, error),
);
}
dispose() {
this.requestId++;
this.texture?.dispose();
this.material.dispose();
}
}