neko pt 2

This commit is contained in:
2026-03-08 02:24:18 +08:00
parent 2633ce0d01
commit d8231bb171
5 changed files with 333 additions and 0 deletions

View File

@@ -8,7 +8,17 @@
currentPresenceState, currentPresenceState,
} from "../../events/user-status"; } from "../../events/user-status";
import { commands } from "$lib/bindings"; import { commands } from "$lib/bindings";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import DebugBar from "./components/debug-bar.svelte"; import DebugBar from "./components/debug-bar.svelte";
import Neko from "./components/neko/neko.svelte";
let spriteUrl = $state("");
$effect(() => {
getSpriteSheetUrl().then((url) => {
spriteUrl = url;
});
});
</script> </script>
<div class="w-svw h-svh p-4 relative overflow-hidden"> <div class="w-svw h-svh p-4 relative overflow-hidden">
@@ -19,6 +29,11 @@
await commands.setSceneInteractive(false, true); await commands.setSceneInteractive(false, true);
}}>&nbsp;</button }}>&nbsp;</button
> >
<Neko
targetX={$cursorPositionOnScreen.raw.x}
targetY={$cursorPositionOnScreen.raw.y}
{spriteUrl}
/>
<div id="debug-bar"> <div id="debug-bar">
<DebugBar <DebugBar
isInteractive={$sceneInteractive} isInteractive={$sceneInteractive}

View File

@@ -0,0 +1,86 @@
import { setSprite, nekoSpeed } from "./sprites";
import type { Position } from "./physics";
export function updateIdle(
nekoEl: HTMLDivElement,
nekoPos: Position,
targetPos: Position,
idleAnimation: string | null,
idleAnimationFrame: number,
idleTime: number,
): {
isIdle: boolean;
idleAnimation: string | null;
idleAnimationFrame: number;
idleTime: number;
} {
const distance = Math.sqrt(
(nekoPos.x - targetPos.x) ** 2 + (nekoPos.y - targetPos.y) ** 2,
);
if (distance >= nekoSpeed && distance >= 48) {
return { isIdle: false, idleAnimation, idleAnimationFrame, idleTime };
}
let newIdleTime = idleTime + 1;
let newIdleAnimation = idleAnimation;
let newIdleFrame = idleAnimationFrame;
if (
newIdleTime > 10 &&
Math.floor(Math.random() * 200) == 0 &&
newIdleAnimation == null
) {
let availableIdleAnimations = ["sleeping", "scratchSelf"];
if (nekoPos.x < 32) {
availableIdleAnimations.push("scratchWallW");
}
if (nekoPos.y < 32) {
availableIdleAnimations.push("scratchWallN");
}
if (nekoPos.x > window.innerWidth - 32) {
availableIdleAnimations.push("scratchWallE");
}
if (nekoPos.y > window.innerHeight - 32) {
availableIdleAnimations.push("scratchWallS");
}
newIdleAnimation =
availableIdleAnimations[
Math.floor(Math.random() * availableIdleAnimations.length)
];
}
switch (newIdleAnimation) {
case "sleeping":
if (newIdleFrame < 8) {
setSprite(nekoEl, "tired", 0);
} else {
setSprite(nekoEl, "sleeping", Math.floor(newIdleFrame / 4));
}
if (newIdleFrame > 192) {
newIdleAnimation = null;
newIdleFrame = 0;
}
break;
case "scratchWallN":
case "scratchWallS":
case "scratchWallE":
case "scratchWallW":
case "scratchSelf":
setSprite(nekoEl, newIdleAnimation, newIdleFrame);
if (newIdleFrame > 9) {
newIdleAnimation = null;
newIdleFrame = 0;
}
break;
default:
setSprite(nekoEl, "idle", 0);
return { isIdle: true, idleAnimation: null, idleAnimationFrame: 0, idleTime: newIdleTime };
}
return {
isIdle: true,
idleAnimation: newIdleAnimation,
idleAnimationFrame: newIdleFrame + 1,
idleTime: newIdleTime,
};
}

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { setSprite } from "./sprites";
import { calculateDirection, moveTowards, clampPosition } from "./physics";
import { updateIdle } from "./idle";
interface Props {
targetX: number;
targetY: number;
spriteUrl: string;
}
let { targetX, targetY, spriteUrl }: Props = $props();
let nekoEl: HTMLDivElement;
let animationFrameId: number;
let nekoPos = $state({ x: 32, y: 32 });
let frameCount = 0;
let idleTime = 0;
let idleAnimation: string | null = $state(null);
let idleAnimationFrame = 0;
let lastFrameTimestamp: number | null = null;
function frame(timestamp: number) {
if (!lastFrameTimestamp) {
lastFrameTimestamp = timestamp;
}
if (timestamp - lastFrameTimestamp > 100) {
lastFrameTimestamp = timestamp;
doFrame();
}
animationFrameId = requestAnimationFrame(frame);
}
function doFrame() {
frameCount += 1;
const targetPos = { x: targetX, y: targetY };
const { direction, distance } = calculateDirection(
nekoPos.x,
nekoPos.y,
targetPos.x,
targetPos.y,
);
if (distance < 10 || distance < 48) {
const idleResult = updateIdle(
nekoEl,
nekoPos,
targetPos,
idleAnimation,
idleAnimationFrame,
idleTime,
);
idleAnimation = idleResult.idleAnimation;
idleAnimationFrame = idleResult.idleAnimationFrame;
idleTime = idleResult.idleTime;
return;
}
idleAnimation = null;
idleAnimationFrame = 0;
if (idleTime > 1) {
setSprite(nekoEl, "alert", 0);
idleTime = Math.min(idleTime, 7);
idleTime -= 1;
return;
}
setSprite(nekoEl, direction, frameCount);
const newPos = moveTowards(nekoPos.x, nekoPos.y, targetPos.x, targetPos.y);
nekoPos = newPos;
nekoEl.style.left = `${nekoPos.x - 16}px`;
nekoEl.style.top = `${nekoPos.y - 16}px`;
}
onMount(() => {
nekoEl.style.backgroundImage = `url(${spriteUrl})`;
animationFrameId = requestAnimationFrame(frame);
});
onDestroy(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
$effect(() => {
if (nekoEl && spriteUrl) {
nekoEl.style.backgroundImage = `url(${spriteUrl})`;
}
});
</script>
<div
bind:this={nekoEl}
class="pointer-events-none fixed z-999 size-8 select-none"
style="width: 32px; height: 32px; position: fixed; image-rendering: pixelated;"
></div>

View File

@@ -0,0 +1,52 @@
import { nekoSpeed } from "./sprites";
export interface Position {
x: number;
y: number;
}
export function calculateDirection(
fromX: number,
fromY: number,
toX: number,
toY: number,
): { direction: string; distance: number } {
const diffX = fromX - toX;
const diffY = fromY - toY;
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
let direction = "";
direction = diffY / distance > 0.5 ? "N" : "";
direction += diffY / distance < -0.5 ? "S" : "";
direction += diffX / distance > 0.5 ? "W" : "";
direction += diffX / distance < -0.5 ? "E" : "";
return { direction, distance };
}
export function moveTowards(
currentX: number,
currentY: number,
targetX: number,
targetY: number,
speed: number = nekoSpeed,
): Position {
const diffX = targetX - currentX;
const diffY = targetY - currentY;
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
if (distance === 0) return { x: currentX, y: currentY };
const newX = currentX + (diffX / distance) * speed;
const newY = currentY + (diffY / distance) * speed;
return clampPosition(newX, newY);
}
export function clampPosition(x: number, y: number): Position {
const margin = 16;
return {
x: Math.min(Math.max(margin, x), window.innerWidth - margin),
y: Math.min(Math.max(margin, y), window.innerHeight - margin),
};
}

View File

@@ -0,0 +1,76 @@
export const SPRITE_SIZE = 32;
export const nekoSpeed = 10;
export const spriteSets: Record<string, [number, number][]> = {
idle: [[-3, -3]],
alert: [[-7, -3]],
scratchSelf: [
[-5, 0],
[-6, 0],
[-7, 0],
],
scratchWallN: [
[0, 0],
[0, -1],
],
scratchWallS: [
[-7, -1],
[-6, -2],
],
scratchWallE: [
[-2, -2],
[-2, -3],
],
scratchWallW: [
[-4, 0],
[-4, -1],
],
tired: [[-3, -2]],
sleeping: [
[-2, 0],
[-2, -1],
],
N: [
[-1, -2],
[-1, -3],
],
NE: [
[0, -2],
[0, -3],
],
E: [
[-3, 0],
[-3, -1],
],
SE: [
[-5, -1],
[-5, -2],
],
S: [
[-6, -3],
[-7, -2],
],
SW: [
[-5, -3],
[-6, -1],
],
W: [
[-4, -2],
[-4, -3],
],
NW: [
[-1, 0],
[-1, -1],
],
};
export function setSprite(
el: HTMLDivElement,
name: string,
frame: number,
): void {
const sprites = spriteSets[name];
if (!sprites) return;
const sprite = sprites[frame % sprites.length];
el.style.backgroundPosition = `${sprite[0] * SPRITE_SIZE}px ${sprite[1] * SPRITE_SIZE}px`;
}