minecraft skins!
This commit is contained in:
+7
-4
@@ -35,8 +35,8 @@ async deleteRemote(id: string) : Promise<boolean> {
|
||||
async getProfile() : Promise<User> {
|
||||
return await TAURI_INVOKE("get_profile");
|
||||
},
|
||||
async updateProfile(displayName: string) : Promise<User> {
|
||||
return await TAURI_INVOKE("update_profile", { displayName });
|
||||
async updateProfile(displayName: string, skinData: number[] | null) : Promise<User> {
|
||||
return await TAURI_INVOKE("update_profile", { displayName, skinData });
|
||||
},
|
||||
async getPublicKey() : Promise<string> {
|
||||
return await TAURI_INVOKE("get_public_key");
|
||||
@@ -50,6 +50,9 @@ async listFriendStatuses() : Promise<string[]> {
|
||||
async resolveFriendDisplayName(userId: string) : Promise<string | null> {
|
||||
return await TAURI_INVOKE("resolve_friend_display_name", { userId });
|
||||
},
|
||||
async resolveSkin(userId: string, skinHash: string) : Promise<string | null> {
|
||||
return await TAURI_INVOKE("resolve_skin", { userId, skinHash });
|
||||
},
|
||||
async pickAndSendImage(recipientId: string) : Promise<boolean> {
|
||||
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
|
||||
},
|
||||
@@ -125,7 +128,7 @@ export type ForegroundAppChanged = { meta: AppMeta }
|
||||
* A configured public-key relationship with optional cached remote metadata.
|
||||
* The display name remains absent until learned from a signed remote profile.
|
||||
*/
|
||||
export type Friend = { id: string; displayName: string | null }
|
||||
export type Friend = { id: string; displayName: string | null; skinHash: string | null }
|
||||
export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
|
||||
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
||||
export type FriendStatusesChanged = { friendIds: string[] }
|
||||
@@ -143,7 +146,7 @@ export type SceneHitbox = { x: number; y: number; width: number; height: number
|
||||
* Public user identity exchanged with peers and remote servers. `id` is the
|
||||
* user's Ed25519 public key and is not persisted as local profile metadata.
|
||||
*/
|
||||
export type User = { id: string; displayName: string }
|
||||
export type User = { id: string; displayName: string; skinHash: string | null }
|
||||
|
||||
/** tauri-specta globals **/
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
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 ?? "";
|
||||
@@ -21,12 +23,15 @@
|
||||
onMount(() => register("general", { apply, reset }, { dirty, busy }));
|
||||
|
||||
function updateDirty() {
|
||||
dirty = displayName.trim() !== ($profile?.displayName ?? "");
|
||||
dirty =
|
||||
displayName.trim() !== ($profile?.displayName ?? "") || skinFile !== null;
|
||||
error = "";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
displayName = $profile?.displayName ?? "";
|
||||
skinFile = null;
|
||||
if (skinInput) skinInput.value = "";
|
||||
dirty = false;
|
||||
error = "";
|
||||
}
|
||||
@@ -42,8 +47,13 @@
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await commands.updateProfile(nextName);
|
||||
const skinData = skinFile
|
||||
? Array.from(new Uint8Array(await skinFile.arrayBuffer()))
|
||||
: null;
|
||||
await commands.updateProfile(nextName, skinData);
|
||||
displayName = nextName;
|
||||
skinFile = null;
|
||||
if (skinInput) skinInput.value = "";
|
||||
dirty = false;
|
||||
return true;
|
||||
} catch (cause) {
|
||||
@@ -78,6 +88,34 @@
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
error = "";
|
||||
|
||||
try {
|
||||
await commands.updateProfile(displayName);
|
||||
await commands.updateProfile(displayName, null);
|
||||
} catch (err) {
|
||||
error = String(err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { commands } from "$lib/bindings";
|
||||
|
||||
export const DEFAULT_SKIN_URL = "/default-skin.png";
|
||||
|
||||
export async function resolveSkinSource(
|
||||
userId: string,
|
||||
skinHash: string | null,
|
||||
) {
|
||||
if (!skinHash) return DEFAULT_SKIN_URL;
|
||||
|
||||
try {
|
||||
const data = await commands.resolveSkin(userId, skinHash);
|
||||
return data ? `data:image/png;base64,${data}` : DEFAULT_SKIN_URL;
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve custom skin for ${userId}`, error);
|
||||
return DEFAULT_SKIN_URL;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user