-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Pong Pang page with canvas animation
- Loading branch information
Showing
5 changed files
with
319 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { Link } from "~/Navigation" | ||
import styles from './styles.module.css' | ||
import { useEffect, useState } from "react" | ||
import type { Incoming } from "./worker" | ||
import Worker from "./worker?worker" | ||
|
||
export const meta = { | ||
title: 'Pong Pang' | ||
} | ||
|
||
export default function PongPang() { | ||
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null) | ||
const [offscreen, setOffscreen] = useState<OffscreenCanvas | null>(null) | ||
useEffect(() => { | ||
if (!offscreen) return | ||
const worker = new Worker() | ||
function post<I extends Incoming["type"]>( | ||
type: I, | ||
data: Extract<Incoming, { type: I }>["data"], | ||
transfer?: Transferable[] | ||
) { | ||
worker.postMessage({ type, data }, { transfer }) | ||
} | ||
|
||
post("canvas", { canvas: offscreen }, [offscreen]) | ||
return () => worker.terminate() | ||
}, [offscreen]) | ||
return ( | ||
<div className={styles.main}> | ||
<Link href="/">back</Link> | ||
<h1>{meta.title}</h1> | ||
<canvas width="1000" height="1000" ref={c => { | ||
if (c && c !== canvas) { | ||
setCanvas(c) | ||
setOffscreen(c.transferControlToOffscreen()) | ||
} | ||
}}> | ||
Your browser does not support the HTML5 canvas tag. | ||
</canvas> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
let nextHandle = 1 // Spec says greater than zero | ||
const tasksByHandle = new Map<number, () => void>() | ||
let currentlyRunningATask = false | ||
|
||
|
||
export function setImmediate(callback: () => void) { | ||
// Copy function arguments | ||
const args = new Array(arguments.length - 1) | ||
for (let i = 0; i < args.length; i++) { | ||
args[i] = arguments[i + 1] | ||
} | ||
// Store and register the task | ||
tasksByHandle.set(nextHandle, callback) | ||
registerImmediate(nextHandle) | ||
return nextHandle++ | ||
} | ||
|
||
export function clearImmediate(handle: number) { | ||
tasksByHandle.delete(handle) | ||
} | ||
|
||
const channel = new MessageChannel() | ||
channel.port1.onmessage = function (event) { | ||
const handle = event.data | ||
|
||
var task = tasksByHandle.get(handle) | ||
if (task) { | ||
currentlyRunningATask = true | ||
try { | ||
task() | ||
} finally { | ||
clearImmediate(handle) | ||
currentlyRunningATask = false | ||
} | ||
} | ||
} | ||
|
||
function registerImmediate(handle: number) { | ||
channel.port2.postMessage(handle) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
.main { | ||
padding: 1em; | ||
background-color: lch(0% 0 0); | ||
height: 100dvh; | ||
background: radial-gradient(circle, #451952 min(250px, 100dvmin), #F39F5A 200%); | ||
|
||
canvas { | ||
position: fixed; | ||
inset: 0; | ||
align-self: center; | ||
justify-self: center; | ||
width: 500px; | ||
height: 500px; | ||
max-width: 100dvmin; | ||
max-height: 100dvmin; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
/// <reference lib="webworker" /> | ||
|
||
import { setImmediate } from "./setImmediate" | ||
|
||
export type Incoming = { type: "canvas", data: { canvas: OffscreenCanvas } } | ||
self.onmessage = (e: MessageEvent<Incoming>) => handleMessage(e.data) | ||
|
||
const BLACK = "#451952" | ||
const WHITE = "#F39F5A" | ||
|
||
function handleMessage(event: Incoming) { | ||
if (event.type === "canvas") { | ||
const ctx = event.data.canvas.getContext("2d")! | ||
ctx.fillStyle = "red" | ||
ctx.fillRect(0, 0, 100, 100) | ||
start(ctx) | ||
} | ||
} | ||
|
||
function start(ctx: OffscreenCanvasRenderingContext2D) { | ||
const size = 24 | ||
const speed = 300 | ||
|
||
const grid = Array.from({ length: size }, (_, y) => Array.from({ length: size }, (_, x) => y > x ? 1 : 0)) | ||
|
||
const w = ctx.canvas.width | ||
const h = ctx.canvas.height | ||
const cellSize = Math.min(w, h) / size | ||
|
||
type Ball = { | ||
x: number | ||
y: number | ||
dx: number | ||
dy: number | ||
ignores: 0 | 1 | ||
} | ||
|
||
const black: Ball = { | ||
x: cellSize + Math.random() * cellSize * 2, | ||
y: h / 2 + Math.random() * cellSize * 2 - cellSize, | ||
dx: 1, | ||
dy: -1, | ||
ignores: 1, | ||
} | ||
const white: Ball = { | ||
x: w - cellSize - Math.random() * cellSize * 2, | ||
y: h / 2 + Math.random() * cellSize * 2 - cellSize, | ||
dx: -1, | ||
dy: 1, | ||
ignores: 0, | ||
} | ||
const radius = cellSize / 2 | ||
const circle = Math.PI * 2 | ||
|
||
const metrics = { | ||
frames: 0, | ||
updates: 0, | ||
time: performance.now(), | ||
fps: 0, | ||
ups: 0, | ||
} | ||
|
||
function render() { | ||
metrics.frames++ | ||
requestAnimationFrame(render) | ||
ctx.clearRect(0, 0, w, h) | ||
|
||
ctx.fillStyle = BLACK | ||
ctx.fillRect(0, 0, w, h) | ||
|
||
for (const row of grid) { | ||
for (const cell of row) { | ||
if (cell) { | ||
ctx.fillStyle = WHITE | ||
ctx.fillRect(0, 0, cellSize + 1, cellSize + 1) | ||
} | ||
ctx.translate(cellSize, 0) | ||
} | ||
ctx.translate(-cellSize * size, cellSize) | ||
} | ||
ctx.resetTransform() | ||
|
||
for (const [x, y] of [[0, 0], [0, 1], [0, -1], [1, 0], [-1, 0]]) { | ||
// draw black | ||
ctx.beginPath() | ||
ctx.fillStyle = BLACK | ||
ctx.arc(black.x + x * w, black.y + y * h, radius, 0, circle) | ||
ctx.fill() | ||
ctx.closePath() | ||
|
||
// draw white | ||
ctx.beginPath() | ||
ctx.fillStyle = WHITE | ||
ctx.arc(white.x + x * w, white.y + y * h, radius, 0, circle) | ||
ctx.fill() | ||
ctx.closePath() | ||
|
||
// // draw black collision candidates | ||
// { | ||
// const xMin = Math.floor((black.x + x * w) / cellSize) - 1 | ||
// const yMin = Math.floor((black.y + y * h) / cellSize) - 1 | ||
// ctx.strokeStyle = "red" | ||
// ctx.strokeRect(xMin * cellSize, yMin * cellSize, cellSize * 3, cellSize * 3) | ||
// } | ||
|
||
// // draw white collision candidates | ||
// { | ||
// const xMin = Math.floor((white.x + x * w) / cellSize) - 1 | ||
// const yMin = Math.floor((white.y + y * h) / cellSize) - 1 | ||
// ctx.strokeStyle = "blue" | ||
// ctx.strokeRect(xMin * cellSize, yMin * cellSize, cellSize * 3, cellSize * 3) | ||
// } | ||
} | ||
|
||
|
||
// // draw metrics | ||
// ctx.font = "24px sans-serif" | ||
// ctx.fillStyle = "red" | ||
// ctx.fillText(`Frame: ${metrics.fps}`, 10, 24) | ||
// ctx.fillText(`Update: ${metrics.ups}`, 10, 48) | ||
|
||
// accumulate metrics over 1s | ||
const now = performance.now() | ||
const delta = now - metrics.time | ||
if (delta < 1000) return | ||
metrics.time = now | ||
metrics.fps = metrics.frames | ||
metrics.ups = metrics.updates | ||
metrics.frames = 0 | ||
metrics.updates = 0 | ||
} | ||
requestAnimationFrame(render) | ||
|
||
let lastTime = 0 | ||
function loop() { | ||
const time = performance.now() | ||
setImmediate(loop) | ||
const delta = time - lastTime | ||
if (delta === 0) return | ||
lastTime = time | ||
metrics.updates++ | ||
|
||
const m = speed * delta / 1000 | ||
|
||
black.x += black.dx * m | ||
black.y += black.dy * m | ||
white.x += white.dx * m | ||
white.y += white.dy * m | ||
|
||
// collision | ||
let collided = true | ||
while (collided) { | ||
collide_resolution: for (const ball of [black, white]) { | ||
collided = false | ||
const xMin = Math.floor(ball.x / cellSize) - 1 | ||
const yMin = Math.floor(ball.y / cellSize) - 1 | ||
for (let y = yMin; y < yMin + 3; y++) { | ||
const wy = y < 0 ? size + y : y % size | ||
for (let x = xMin; x < xMin + 3; x++) { | ||
const wx = x < 0 ? size + x : x % size | ||
if (grid[wy][wx] === ball.ignores) continue | ||
const collision = computeCollision(ball.x, ball.y, radius, x * cellSize, y * cellSize, cellSize) | ||
if (!collision) continue | ||
collided = true | ||
grid[wy][wx] = ball.ignores | ||
if (collision === 'x') { | ||
ball.dx *= -1 | ||
const contact = x * cellSize + (ball.dx > 0 ? (cellSize + radius) : (-radius)) | ||
ball.x += Math.abs(ball.x - contact) * ball.dx * 2 | ||
} else { | ||
ball.dy *= -1 | ||
const contact = y * cellSize + (ball.dy > 0 ? (cellSize + radius) : (-radius)) | ||
ball.y += Math.abs(ball.y - contact) * ball.dy * 2 | ||
} | ||
break collide_resolution | ||
} | ||
} | ||
} | ||
} | ||
|
||
// wrap around | ||
black.x = (black.x + w) % w | ||
black.y = (black.y + h) % h | ||
white.x = (white.x + w) % w | ||
white.y = (white.y + h) % h | ||
} | ||
setImmediate(loop) | ||
} | ||
|
||
/** | ||
* @returns false if there is no collision, 'x' if the ball collided with the x-axis, 'y' if the ball collided with the y-axis | ||
*/ | ||
function computeCollision( | ||
ballX: number, | ||
ballY: number, | ||
ballRadius: number, | ||
squareX: number, | ||
squareY: number, | ||
squareSize: number | ||
) { | ||
const x = Math.max(squareX, Math.min(ballX, squareX + squareSize)) | ||
const y = Math.max(squareY, Math.min(ballY, squareY + squareSize) | ||
) | ||
const dx = ballX - x | ||
const dy = ballY - y | ||
const collides = dx * dx + dy * dy <= ballRadius * ballRadius | ||
if (!collides) return false | ||
if (Math.abs(dx) > Math.abs(dy)) { | ||
return 'x' | ||
} else { | ||
return 'y' | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters