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+ }
0 commit comments