Files
pihkaal-me/app/components/Screen.vue
2026-02-27 00:55:25 +01:00

240 lines
6.4 KiB
Vue

<script lang="ts" setup>
const canvas = useTemplateRef("canvas");
const renderCallbacks = new Map<RenderCallback, number>();
const screenClickCallbacks = new Set<ScreenClickCallback>();
const screenMouseDownCallbacks = new Set<ScreenClickCallback>();
const screenMouseWheelCallbacks = new Set<ScreenMouseWheelCallback>();
let ctx: CanvasRenderingContext2D | null = null;
let animationFrameId: number | null = null;
let lastFrameTime = 0;
let lastRealFrameTime = 0;
const registerRenderCallback = (callback: RenderCallback, zIndex = 0) => {
renderCallbacks.set(callback, zIndex);
return () => renderCallbacks.delete(callback);
};
const registerScreenClickCallback = (callback: ScreenClickCallback) => {
screenClickCallbacks.add(callback);
return () => screenClickCallbacks.delete(callback);
};
const registerScreenMouseDownCallback = (callback: ScreenClickCallback) => {
screenMouseDownCallbacks.add(callback);
return () => screenMouseDownCallbacks.delete(callback);
};
const registerScreenMouseWheelCallback = (
callback: ScreenMouseWheelCallback,
) => {
screenMouseWheelCallbacks.add(callback);
return () => screenMouseWheelCallbacks.delete(callback);
};
const toLogicalCoords = (clientX: number, clientY: number) => {
const rect = canvas.value!.getBoundingClientRect();
const scaleX = LOGICAL_WIDTH / rect.width;
const scaleY = LOGICAL_HEIGHT / rect.height;
return [
(clientX - rect.left) * scaleX,
(clientY - rect.top) * scaleY,
] as const;
};
const handleCanvasClick = (event: MouseEvent) => {
if (!canvas.value) return;
const [x, y] = toLogicalCoords(event.clientX, event.clientY);
for (const callback of screenClickCallbacks) {
callback(x, y);
}
};
const handleCanvasMouseDown = (event: MouseEvent) => {
if (!canvas.value) return;
const [x, y] = toLogicalCoords(event.clientX, event.clientY);
for (const callback of screenMouseDownCallbacks) {
callback(x, y);
}
};
const handleCanvasWheel = (event: WheelEvent) => {
for (const callback of screenMouseWheelCallbacks) {
callback(event.deltaY, event.deltaX);
}
};
const SWIPE_THRESHOLD = 30;
let swipeStartX = 0;
let swipeStartY = 0;
const dispatchSwipe = (endX: number, endY: number) => {
const deltaX = endX - swipeStartX;
const deltaY = endY - swipeStartY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (Math.max(absDeltaX, absDeltaY) < SWIPE_THRESHOLD) return;
let direction: string;
if (absDeltaX > absDeltaY) {
direction = deltaX > 0 ? "RIGHT" : "LEFT";
} else {
direction = deltaY > 0 ? "DOWN" : "UP";
}
window.dispatchEvent(
new KeyboardEvent("keydown", { key: `NDS_SWIPE_${direction}` }),
);
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
if (event.cancelable) event.preventDefault();
swipeStartX = touch.clientX;
swipeStartY = touch.clientY;
canvas.value?.dispatchEvent(
new MouseEvent("mousedown", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
};
const handleTouchEnd = (event: TouchEvent) => {
const touch = event.changedTouches[0];
if (!touch) return;
dispatchSwipe(touch.clientX, touch.clientY);
canvas.value?.dispatchEvent(
new MouseEvent("click", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
document.dispatchEvent(
new MouseEvent("mouseup", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
};
let mouseSwiping = false;
const handleSwipeMouseDown = (event: MouseEvent) => {
mouseSwiping = true;
swipeStartX = event.clientX;
swipeStartY = event.clientY;
};
const handleSwipeMouseUp = (event: MouseEvent) => {
if (!mouseSwiping) return;
mouseSwiping = false;
dispatchSwipe(event.clientX, event.clientY);
};
const renderFrame = (timestamp: number) => {
if (!ctx) return;
const deltaTime = timestamp - lastFrameTime;
lastFrameTime = timestamp;
const start = Date.now();
// render
ctx.save();
ctx.scale(SCREEN_SCALE, SCREEN_SCALE);
ctx.clearRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
const sortedCallbacks = Array.from(renderCallbacks.entries())
.sort((a, b) => a[1] - b[1])
.map(([callback]) => callback);
for (const callback of sortedCallbacks) {
ctx.save();
try {
callback(ctx, deltaTime, lastRealFrameTime);
} catch (error: unknown) {
console.error(error);
}
ctx.restore();
}
ctx.restore();
lastRealFrameTime = Date.now() - start;
animationFrameId = requestAnimationFrame(renderFrame);
};
provide("registerRenderCallback", registerRenderCallback);
provide("registerScreenClickCallback", registerScreenClickCallback);
provide("registerScreenMouseDownCallback", registerScreenMouseDownCallback);
provide("registerScreenMouseWheelCallback", registerScreenMouseWheelCallback);
onMounted(() => {
if (!canvas.value) throw new Error("Missing canvas");
ctx = canvas.value.getContext("2d");
if (!ctx) throw new Error("Missing 2d context");
ctx.imageSmoothingEnabled = false;
canvas.value.addEventListener("click", handleCanvasClick);
canvas.value.addEventListener("mousedown", handleCanvasMouseDown);
canvas.value.addEventListener("wheel", handleCanvasWheel, { passive: true });
canvas.value.addEventListener("touchstart", handleTouchStart, {
passive: false,
});
canvas.value.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.value.addEventListener("mousedown", handleSwipeMouseDown);
canvas.value.addEventListener("mouseup", handleSwipeMouseUp);
animationFrameId = requestAnimationFrame(renderFrame);
});
onUnmounted(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
if (canvas.value) {
canvas.value.removeEventListener("click", handleCanvasClick);
canvas.value.removeEventListener("mousedown", handleCanvasMouseDown);
canvas.value.removeEventListener("wheel", handleCanvasWheel);
canvas.value.removeEventListener("touchstart", handleTouchStart);
canvas.value.removeEventListener("touchend", handleTouchEnd);
canvas.value.removeEventListener("mousedown", handleSwipeMouseDown);
canvas.value.removeEventListener("mouseup", handleSwipeMouseUp);
}
});
defineExpose({
canvas,
});
</script>
<template>
<canvas
ref="canvas"
:width="SCREEN_WIDTH"
:height="SCREEN_HEIGHT"
:style="{
margin: '0',
border: '1px solid red',
}"
/>
<slot v-if="canvas" />
</template>