WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 47cd6ed

Browse files
committed
suika
1 parent a839cdd commit 47cd6ed

File tree

3 files changed

+310
-2
lines changed

3 files changed

+310
-2
lines changed

src/pages/suika/index.tsx

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import styles from './styles.module.css'
2+
import { Head } from "#components/Head"
3+
import type { RouteMeta } from "#router"
4+
import { useEffect, useRef, useState } from "react"
5+
6+
export const meta: RouteMeta = {
7+
title: 'Suika Game',
8+
tags: ['game'],
9+
}
10+
export default function SuikaGamePage() {
11+
const canvasRef = useRef<HTMLCanvasElement>(null)
12+
const [score, setScore] = useState(0)
13+
14+
useEffect(() => {
15+
const canvas = canvasRef.current!
16+
17+
canvas.width = window.innerWidth * devicePixelRatio
18+
canvas.height = window.innerHeight * devicePixelRatio
19+
canvas.style.width = `${window.innerWidth}px`
20+
canvas.style.height = `${window.innerHeight}px`
21+
22+
const ctx = canvas.getContext('2d')!
23+
ctx.scale(devicePixelRatio, devicePixelRatio)
24+
25+
const controller = new AbortController()
26+
start(controller.signal, ctx, setScore)
27+
28+
return () => {
29+
controller.abort()
30+
}
31+
}, [])
32+
33+
return (
34+
<div className={styles.main}>
35+
<div className={styles.head}>
36+
<Head />
37+
<output>Score: {score}</output>
38+
</div>
39+
<canvas ref={canvasRef} className={styles.canvas} />
40+
</div>
41+
)
42+
}
43+
44+
/**
45+
* When 2 entities of the same level (index) touch,
46+
* they fuse in 1 single entity of the next level.
47+
*
48+
* The new entity is created at the position of the touch,
49+
* with velocity being the average of the 2 original entities.
50+
*
51+
* This earns the player points (score).
52+
*/
53+
const CHAIN = [
54+
{ r: 10, color: 'red', score: 0 },
55+
{ r: 20, color: 'orange', score: 1 },
56+
{ r: 30, color: 'yellow', score: 3 },
57+
{ r: 40, color: 'green', score: 5 },
58+
{ r: 50, color: 'blue', score: 7 },
59+
{ r: 60, color: 'indigo', score: 11 },
60+
{ r: 70, color: 'violet', score: 20 },
61+
{ r: 80, color: 'black', score: 50 },
62+
{ r: 90, color: 'white', score: 100 },
63+
{ r: 100, color: 'gold', score: 200 },
64+
]
65+
66+
type Entity = {
67+
/** index in CHAIN */
68+
id: number
69+
r: number
70+
color: string
71+
x: number
72+
y: number
73+
vx: number
74+
vy: number
75+
}
76+
77+
function start(signal: AbortSignal, ctx: CanvasRenderingContext2D, setScore: (update: (prev: number) => number) => void) {
78+
/** All entities currently in the game */
79+
const entities: Entity[] = []
80+
/** the maximum level (index) of entity present in the game */
81+
let max = 0
82+
let nextId = 0
83+
/** what will be dropped when the user clicks (index in CHAIN) */
84+
let handId = 0
85+
/** preview of the next handId (index in CHAIN), will become handId when the user clicks */
86+
87+
/** position at which to drop the new entity (handId) on click */
88+
let mouseX = 0
89+
90+
window.addEventListener('mousemove', (e) => {
91+
mouseX = e.clientX
92+
}, { signal })
93+
94+
window.addEventListener('click', () => {
95+
const base = CHAIN[handId]
96+
const x = Math.max(containerX + base.r, Math.min(containerX + CONTAINER_WIDTH - base.r, mouseX))
97+
entities.push({
98+
id: handId,
99+
r: base.r,
100+
color: base.color,
101+
x,
102+
y: base.r,
103+
vx: 0,
104+
vy: 0,
105+
})
106+
handId = nextId
107+
nextId = Math.floor(Math.random() * max + 1)
108+
}, { signal })
109+
110+
const CONTAINER_WIDTH = 700
111+
const CONTAINER_HEIGHT = 1000
112+
const containerX = ctx.canvas.width / devicePixelRatio / 2 - CONTAINER_WIDTH / 2
113+
const containerY = ctx.canvas.height / devicePixelRatio - CONTAINER_HEIGHT
114+
115+
let lastTime = performance.now()
116+
let rafId = requestAnimationFrame(function loop(time) {
117+
rafId = requestAnimationFrame(loop)
118+
const dt = (time - lastTime) / 16.6667
119+
lastTime = time
120+
if (dt > 1) return // skip frame if too much time has passed
121+
122+
const steps = Math.floor(dt / 0.1)
123+
124+
for (let step = 0; step < steps; step++) {
125+
const dti = dt / steps
126+
127+
// Update entities
128+
for (const entity of entities) {
129+
entity.vy += 0.4 * dti // gravity
130+
entity.x += entity.vx * dti
131+
entity.y += entity.vy * dti
132+
133+
// floor collision
134+
if (entity.y + entity.r > window.innerHeight) {
135+
entity.y = window.innerHeight - entity.r
136+
entity.vy *= -0.3
137+
}
138+
139+
// wall collisions
140+
if (entity.x - entity.r < containerX) {
141+
entity.x = containerX + entity.r
142+
entity.vx *= -0.5
143+
}
144+
if (entity.x + entity.r > containerX + CONTAINER_WIDTH) {
145+
entity.x = containerX + CONTAINER_WIDTH - entity.r
146+
entity.vx *= -0.5
147+
}
148+
if (entity.y + entity.r > containerY + CONTAINER_HEIGHT) {
149+
entity.y = containerY + CONTAINER_HEIGHT - entity.r
150+
entity.vy *= -0.5
151+
}
152+
}
153+
154+
// Check for merges
155+
for (let i = 0; i < entities.length; i++) {
156+
for (let j = i + 1; j < entities.length; j++) {
157+
const a = entities[i]
158+
if (a.id >= CHAIN.length - 1) continue
159+
const b = entities[j]
160+
if (a.id !== b.id) continue
161+
const dx = a.x - b.x
162+
const dy = a.y - b.y
163+
const dist = Math.hypot(dx, dy)
164+
if (dist > a.r + b.r) continue
165+
// Merge
166+
const newId = a.id + 1
167+
const base = CHAIN[newId]!
168+
entities.push({
169+
id: newId,
170+
r: base.r,
171+
color: base.color,
172+
x: (a.x + b.x) / 2,
173+
y: (a.y + b.y) / 2,
174+
vx: (a.vx + b.vx) / 2,
175+
vy: (a.vy + b.vy) / 2,
176+
})
177+
max = Math.max(max, newId)
178+
setScore(prev => prev + base.score)
179+
// Remove merged entities
180+
entities.splice(j, 1)
181+
entities.splice(i, 1)
182+
i--
183+
break
184+
}
185+
}
186+
187+
// Check for collisions
188+
for (let i = 0; i < entities.length; i++) {
189+
for (let j = i + 1; j < entities.length; j++) {
190+
const a = entities[i]
191+
const b = entities[j]
192+
const dx = a.x - b.x
193+
const dy = a.y - b.y
194+
const dist = Math.hypot(dx, dy)
195+
const minDist = a.r + b.r
196+
if (dist > minDist) continue
197+
198+
// Collision detected - separate entities
199+
const overlap = minDist - dist
200+
const separationX = (dx / dist) * overlap * 0.5
201+
const separationY = (dy / dist) * overlap * 0.5
202+
203+
a.x += separationX
204+
a.y += separationY
205+
b.x -= separationX
206+
b.y -= separationY
207+
208+
// Apply collision response (elastic collision)
209+
const normalX = dx / dist
210+
const normalY = dy / dist
211+
const relativeVelX = a.vx - b.vx
212+
const relativeVelY = a.vy - b.vy
213+
const speed = relativeVelX * normalX + relativeVelY * normalY
214+
215+
if (speed > 0) continue // Objects separating
216+
217+
const massA = a.r ** 3
218+
const massB = b.r ** 3
219+
const impulse = 2 * speed / (massA + massB) * 0.8 // restitution coefficient
220+
a.vx -= impulse * massB * normalX
221+
a.vy -= impulse * massB * normalY
222+
b.vx += impulse * massA * normalX
223+
b.vy += impulse * massA * normalY
224+
}
225+
}
226+
}
227+
228+
// Render frame
229+
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
230+
231+
// Draw walls
232+
ctx.fillStyle = 'black'
233+
ctx.fillRect(containerX - 10, containerY - 10, 10, CONTAINER_HEIGHT + 20) // left
234+
ctx.fillRect(containerX + CONTAINER_WIDTH, containerY - 10, 10, CONTAINER_HEIGHT + 20) // right
235+
ctx.fillRect(containerX - 10, containerY + CONTAINER_HEIGHT, CONTAINER_WIDTH + 20, 10) // bottom
236+
237+
// Draw hand
238+
{
239+
const base = CHAIN[handId]
240+
ctx.fillStyle = base.color
241+
ctx.beginPath()
242+
const x = Math.max(containerX + base.r, Math.min(containerX + CONTAINER_WIDTH - base.r, mouseX))
243+
ctx.arc(x, 50, base.r, 0, Math.PI * 2)
244+
ctx.fill()
245+
}
246+
247+
// Draw next hand preview (top right corner)
248+
{
249+
const base = CHAIN[nextId]
250+
ctx.fillStyle = base.color
251+
ctx.globalAlpha = 0.5
252+
ctx.beginPath()
253+
ctx.arc(window.innerWidth - 50, 50, base.r, 0, Math.PI * 2)
254+
ctx.fill()
255+
ctx.globalAlpha = 1
256+
}
257+
258+
// Draw entities
259+
for (const entity of entities) {
260+
ctx.fillStyle = entity.color
261+
ctx.beginPath()
262+
ctx.arc(entity.x, entity.y, entity.r, 0, Math.PI * 2)
263+
ctx.fill()
264+
}
265+
})
266+
267+
signal.addEventListener('abort', () => {
268+
cancelAnimationFrame(rafId)
269+
}, { signal })
270+
}

src/pages/suika/styles.module.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.main {
2+
margin: 0;
3+
background: #051016;
4+
color: white;
5+
touch-action: none;
6+
width: 100vw;
7+
height: 100svh;
8+
padding: 1em;
9+
10+
position: fixed;
11+
inset: 0;
12+
overflow: auto;
13+
14+
canvas {
15+
position: fixed;
16+
inset: 0;
17+
z-index: 0;
18+
pointer-events: none;
19+
}
20+
}
21+
22+
.head {
23+
position: relative;
24+
z-index: 1;
25+
user-select: none;
26+
width: fit-content;
27+
}

src/router.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import ants_shader_image from "./pages/ants-shader/screen.png"
3939
import ants_image from "./pages/ants/screen.png"
4040
import a_star_image from "./pages/a-star/screen.png"
4141

42-
export type Routes = "wave-function-collapse-ascii" | "wave-function-collapse" | "visual-exec" | "tinkerbell-map" | "swarm-pathfinding" | "star-rating" | "spring-fluid" | "spider-inverse-kinematics" | "snakebird" | "silky-modal" | "quad-tree-collisions" | "quad-tree" | "pong-pang" | "polka-fireflies" | "pinball" | "perlin-ripples" | "particle-life-gpu" | "particle-life" | "paint-worklet" | "pacman" | "normal-map" | "neat" | "modern-modal" | "minesweeper" | "maze-generation" | "lightning" | "intl-tuesday" | "hex-a-star" | "hacker-background" | "grainy-texture" | "fragment-portal" | "fourrier-series" | "flow-field" | "flask" | "fireflies" | "deterministic-plinko" | "cursor-projection" | "collision-threads" | "cellular-automata" | "boids" | "bird-inverse-kinematics" | "any-string-to-css-color" | "ants-shader" | "ants" | "a-star"
42+
export type Routes = "wave-function-collapse-ascii" | "wave-function-collapse" | "visual-exec" | "tinkerbell-map" | "swarm-pathfinding" | "suika" | "star-rating" | "spring-fluid" | "spider-inverse-kinematics" | "snakebird" | "silky-modal" | "quad-tree-collisions" | "quad-tree" | "pong-pang" | "polka-fireflies" | "pinball" | "perlin-ripples" | "particle-life-gpu" | "particle-life" | "paint-worklet" | "pacman" | "normal-map" | "neat" | "modern-modal" | "minesweeper" | "maze-generation" | "lightning" | "intl-tuesday" | "hex-a-star" | "hacker-background" | "grainy-texture" | "fragment-portal" | "fourrier-series" | "flow-field" | "flask" | "fireflies" | "deterministic-plinko" | "cursor-projection" | "collision-threads" | "cellular-automata" | "boids" | "bird-inverse-kinematics" | "any-string-to-css-color" | "ants-shader" | "ants" | "a-star"
4343

4444
export type RouteMeta = {
4545
title: string
@@ -119,6 +119,17 @@ export const ROUTES = {
119119
firstAdded: 1741025883000
120120
},
121121
},
122+
"suika": {
123+
Component: lazy(() => import("./pages/suika/index.tsx")),
124+
meta: {
125+
title: 'Suika Game',
126+
tags: ['game'],
127+
},
128+
git: {
129+
lastModified: 0,
130+
firstAdded: 0
131+
},
132+
},
122133
"star-rating": {
123134
Component: lazy(() => import("./pages/star-rating/index.tsx")),
124135
meta: {
@@ -235,7 +246,7 @@ export const ROUTES = {
235246

236247
},
237248
git: {
238-
lastModified: 1764453472000,
249+
lastModified: 1764505750000,
239250
firstAdded: 1764431682000
240251
},
241252
},

0 commit comments

Comments
 (0)