22import Key from " @/ui-components/Key.svelte" ;
33import { onMount } from " svelte" ;
44import " @/ui-components/menu/menu.css" ;
5- import { replaceState } from " $app/navigation" ;
5+ import { goto } from " $app/navigation" ;
6+ import { MAX_WORLD , SearchParamState } from " ./params.svelte.ts" ;
67
7- // starts from 1
8- let world = $state <number | null >(null );
9- let stage = $state <number | null >(null );
10- const maxWorld = 4 ;
11- const maxStage = 4 ;
8+ const search = new SearchParamState (1 );
129
13- onMount (() => {
14- const params = new URLSearchParams (window .location .search );
15- world = Number (params .get (" w" ) ?? " 1" );
16- stage = Number (params .get (" s" ) ?? " 1" );
17- });
10+ const blocks = $derived (
11+ new Array (search .maxStage ).fill (null ).map ((_ , idx ) => ({
12+ label: ` ${search .world }-${idx + 1 } ` ,
13+ link: ` /game?stage=${search .world }-${idx + 1 } ` ,
14+ thumbnail: ` /assets/thumbnail${search .world }-${idx + 1 }.png ` ,
15+ })),
16+ );
1817
1918let lastKeyTime = 0 ;
2019let lastKey: string | null = null ;
2120const KEY_REPEAT_DELAY = 180 ; // ms
2221
23- function changeStage(worldNum : number , stageNum : number ): void {
24- if (worldNum < 1 || worldNum > maxWorld ) {
25- throw new Error (` World number must be between 1 and ${maxWorld }, but got ${worldNum } ` );
26- }
27- if (stageNum < 1 || stageNum > maxStage ) {
28- throw new Error (` Stage number must be between 1 and ${maxStage }, but got ${stageNum } ` );
29- }
30- world = worldNum ;
31- stage = stageNum ;
32- const url = new URL (window .location .href );
33- url .searchParams .set (" w" , String (world ));
34- url .searchParams .set (" s" , String (stageNum ));
35- replaceState (url .toString (), {});
36- }
37-
3822function handleKey(e : KeyboardEvent ): void {
3923 const now = Date .now ();
4024 if (e .key === " ArrowRight" || e .key === " ArrowLeft" ) {
@@ -45,31 +29,52 @@ function handleKey(e: KeyboardEvent): void {
4529 lastKey = e .key ;
4630 }
4731
48- if (stage === null || world === null ) {
32+ if (search . selected === null ) {
4933 return ;
5034 }
5135
5236 if (e .key === " ArrowRight" ) {
53- if (stage === maxStage ) {
54- if (world < maxWorld ) {
55- changeStage (world + 1 , 1 );
37+ if (search .selected === blocks .length - 1 ) {
38+ if (search .world !== 4 ) {
39+ search .nextWorld ();
40+ search .selected = 1 ;
5641 }
5742 } else {
58- changeStage ( world , stage + 1 ) ;
43+ search . selected += 1 ;
5944 }
6045 } else if (e .key === " ArrowLeft" ) {
61- if (stage === 1 ) {
62- if (world > 1 ) {
63- changeStage (world - 1 , maxStage );
46+ if (search .selected === 0 ) {
47+ if (search .world !== 1 ) {
48+ search .prevWorld ();
49+ search .selected = blocks .length - 1 ;
6450 }
6551 } else {
66- changeStage ( world , stage - 1 ) ;
52+ search . selected -= 1 ;
6753 }
6854 } else if (e .key === " Enter" || e .key === " " ) {
69- window . location . href = ` /game?stage=${world }-${stage } ` ;
55+ goto ( ` /game?stage=${search . world }-${search . selected } ` ) ;
7056 } else if (e .key === " Escape" ) {
71- window . location . href = " /" ;
57+ goto ( " /" ) ;
7258 e .preventDefault ();
59+ if (search .selected === blocks .length - 1 ) {
60+ if (search .world !== 4 ) {
61+ search .nextWorld ();
62+ search .selected = 1 ;
63+ }
64+ } else {
65+ search .selected += 1 ;
66+ }
67+ } else if (e .key === " ArrowLeft" ) {
68+ if (search .selected === 0 ) {
69+ if (search .world !== 1 ) {
70+ search .prevWorld ();
71+ search .selected = blocks .length - 1 ;
72+ }
73+ } else {
74+ search .selected -= 1 ;
75+ }
76+ } else if (e .key === " Enter" || e .key === " " ) {
77+ goto (blocks [search .selected ].link );
7378 }
7479}
7580function handleKeyUp() {
@@ -101,44 +106,48 @@ onMount(() => {
101106 <div class =" text-7xl text-center flex items-center justify-center gap-8" >
102107 <!-- 左矢印ボタン -->
103108 <button
104- class ="appearance-none focus:outline-none px-4 select-none cursor-pointer { Number ( world ) <= 1
105- ? ' invisible '
106- : ' ' } hover:-translate-y-1 hover:text-gray-700 active:translate-y-0 active:text-black"
107- aria-label = " 前のワールド "
108- onclick ={() => world !== null && world > 1 && changeStage ( world - 1 , 1 ) }
109- disabled ={Number ( world ) <= 1 }
109+ class ={[
110+ " appearance-none focus:outline-none px-4 select-none cursor-pointer " ,
111+ search . world <= 1 && ' invisible ' ,
112+ " hover:-translate-y-1 hover:text-gray-700 active:translate-y-0 active:text-black " ]}
113+ onclick ={() => search . world -- }
114+ disabled ={search . world <= 1 }
110115 >
111116 <
112117 </button >
113- <span >World {world }</span >
118+ <span >World {search . world }</span >
114119 <!-- 右矢印ボタン -->
115120 <button
116- class ="appearance-none focus:outline-none px-4 select-none cursor-pointer {Number (world ) >= maxWorld
117- ? ' invisible'
118- : ' ' } hover:-translate-y-1 hover:text-gray-700 active:translate-y-0 active:text-black"
119121 aria-label =" 次のワールド"
120- onclick ={() => world !== null && world < maxWorld && changeStage (world + 1 , 1 )}
121- disabled ={Number (world ) >= maxWorld }
122+ onclick ={() => search .world ++ }
123+ disabled ={search .world >= MAX_WORLD }
124+ class ={[
125+ " appearance-none focus:outline-none px-4 select-none cursor-pointer" ,
126+ search .world >= MAX_WORLD && ' invisible' ,
127+ " hover:-translate-y-1 hover:text-gray-700 active:translate-y-0 active:text-black"
128+ ]}
122129 >
123130 >
124131 </button >
125132 </div >
126133
127134 <div class =" flex justify-center items-center grow-1" >
128135 <div role =" button" tabindex =" 0" class =" flex outline-none items-center" >
129- {#each { length : maxStage } as _ , idx }
136+ {#each blocks as block , idx }
137+ {@const stage = idx + 1 }
130138 <button
131139 type =" button"
132140 class ={` appearance-none focus:outline-none bg-white border-6 pt-8 pb-6 pl-8 pr-6 transition-colors duration-200 text-7xl cursor-pointer ${
133- stage === idx + 1
141+ search . selected === stage
134142 ? " border-red-500 ring ring-red-500 bg-amber-100!"
135143 : " border-base"
136144 } ` }
137- onclick ={() => world !== null && changeStage (world , idx + 1 )}
145+ onmouseenter ={() => search .selected = stage }
146+ onclick ={() => goto (block .link )}
138147 >
139- {idx + 1 }
148+ {block . label }
140149 </button >
141- {#if idx + 1 < maxStage }
150+ {#if stage < search . maxStage }
142151 <!-- 線(矢印や線) -->
143152 <div class =" w-20 h-3 bg-black" ></div >
144153 {/if }
@@ -150,23 +159,34 @@ onMount(() => {
150159 >
151160 <!-- 画像を中央に配置 -->
152161 <div class =" h-full" >
153- {#if stage !== null }
154- {#key world }
155- {#each { length : 4 } as _ , idx }
162+ {#if search .selected !== null }
163+ {#key search .world }
164+ {#each blocks as block , idx }
165+ {@const stage = idx + 1 }
156166 <img
157- src ="/assets/thumbnail { world } - { idx + 1 } .png"
167+ src ={ block . thumbnail }
158168 alt =" "
159- class ={[" h-full skeleton" , idx + 1 !== stage && " hidden" ]}
169+ class ={[" h-full skeleton" , stage !== search . selected && " hidden" ]}
160170 />
161171 {/each }
162172 {/ key }
163173 {/if }
164174 </div >
165175 <!-- テキストを画像の右側に配置 -->
166176 <div
167- class =" flex-none w-max max-h-full flex flex-col items-start bg-white/90 p-4 m-4 rounded-lg border-2"
177+ class =" flex-none w-70 max-h-full bg-white/90 p-4 m-4 rounded-lg border-2"
168178 >
169- Press <Key key =" Enter" enabled /> or <Key key =" Space" enabled /> to start
179+ <div >
180+ Click,
181+ </div >
182+ <div >
183+ Press <Key key =" Enter" enabled />
184+ </div >
185+ <div >or Press <Key key =" Space" enabled />
186+ </div >
187+ <div >
188+ To Start
189+ </div >
170190 </div >
171191 </div >
172192 </div >
0 commit comments