neko pt 2
This commit is contained in:
@@ -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);
|
||||||
}}> </button
|
}}> </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}
|
||||||
|
|||||||
86
src/routes/scene/components/neko/idle.ts
Normal file
86
src/routes/scene/components/neko/idle.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
104
src/routes/scene/components/neko/neko.svelte
Normal file
104
src/routes/scene/components/neko/neko.svelte
Normal 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>
|
||||||
52
src/routes/scene/components/neko/physics.ts
Normal file
52
src/routes/scene/components/neko/physics.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/routes/scene/components/neko/sprites.ts
Normal file
76
src/routes/scene/components/neko/sprites.ts
Normal 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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user