Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/tetris.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
7 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
9 <meta name="apple-mobile-web-app-title" content="비나래 아카이브 - 테트리스">
10 <meta property="og:image" content="/image/2.png" />
11 <meta property="og:description" content="morikubo"/>
12 <meta property="og:url" content="hinana.moe"/>
13 <meta property="og:title" content="비나래 아카이브 - 테트리스"/>
14 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
15 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
16 <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet" type="text/css">
17 <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" type="text/css">
18 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
19 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
20 <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
21 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh" crossorigin="anonymous"></script>
22 <link rel="stylesheet" href="/css/hinana.css">
23 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
24
25 <style>
26 /* [Theme Variables] */
27 :root {
28 --bg-main: #e4dfd7; --bg-secondary: #f2f0eb; --bg-tertiary: #dcd6ce;
29 --text-primary: #3e3a36; --text-secondary: #69615c;
30 --accent-color: #b45309; --border-color: #ccc6bc;
31 --hinana-glow: rgba(255, 142, 13, 0.3);
32 --font-family: 'Noto Sans KR', sans-serif;
33 }
34 body.dark-mode {
35 --bg-main: #1c1917; --bg-secondary: #292524; --bg-tertiary: #44403c;
36 --text-primary: #e7e5e4; --text-secondary: #a8a29e;
37 --border-color: #44403c; --accent-color: #f59e0b;
38 --hinana-glow: rgba(245, 158, 11, 0.2);
39 }
40
41 html, body { height: 100%; margin: 0; background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; touch-action: auto !important; font-family: var(--font-family); }
42
43 .global-header { height: 60px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; transition: all 0.3s ease; }
44 .layout-container { display: flex; height: calc(100vh - 60px); transition: all 0.3s ease; }
45
46 .shelf-column { width: 300px; background-color: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; justify-content: center; align-items: center; }
47 .info-column { width: 260px; background-color: var(--bg-secondary); border-left: 1px solid var(--border-color); padding: 20px; }
48
49 .content-column {
50 flex: 1; display: flex; flex-direction: column;
51 align-items: center; justify-content: center;
52 background-color: var(--bg-main); position: relative;
53 background-image: radial-gradient(circle at center, var(--bg-secondary) 0%, var(--bg-main) 70%);
54 transition: all 0.3s ease;
55 }
56
57 .game-board-wrapper { display: flex; align-items: flex-start; gap: 15px; position: relative; z-index: 5; }
58 .side-panel { display: flex; flex-direction: column; gap: 10px; }
59 .panel-box { background: rgba(0,0,0,0.2); border: 2px solid var(--border-color); border-radius: 8px; padding: 10px; text-align: center; width: 90px; }
60 .panel-title { font-size: 0.8rem; font-weight: bold; color: var(--text-secondary); margin-bottom: 5px; }
61
62 canvas.main-canvas {
63 border: 3px solid var(--accent-color); border-radius: 8px;
64 background: radial-gradient(circle at center, #3a2e2e 0%, #1a1515 100%);
65 box-shadow: 0 10px 30px var(--hinana-glow), inset 0 0 20px rgba(0,0,0,0.5); display: block;
66 }
67 canvas.mini-canvas { display: block; margin: 0 auto; background: transparent; }
68
69 .game-info {
70 position: relative; width: 100%; text-align: center; z-index: 20; margin-bottom: 15px;
71 }
72 .game-info-inner { display: flex; justify-content: center; gap: 20px; align-items: baseline; }
73 .score-box { font-size: 2rem; font-weight: 800; color: var(--accent-color); text-shadow: 1px 1px 2px rgba(0,0,0,0.1); }
74 .timer-box { font-size: 1rem; font-weight: bold; color: var(--text-secondary); }
75 .level-box { font-size: 1.2rem; font-weight: 800; color: #ff3385; text-shadow: 1px 1px 2px rgba(0,0,0,0.1); transition: transform 0.2s; }
76 .level-up-anim { color: #fff !important; transform: scale(1.5); text-shadow: 0 0 10px #ff3385; }
77
78 .start-overlay {
79 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
80 background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);
81 display: flex; flex-direction: column;
82 justify-content: center; align-items: center; color: white; z-index: 100;
83 }
84 .btn-start {
85 padding: 15px 40px; font-size: 1.3rem; font-weight: bold;
86 background: linear-gradient(45deg, var(--accent-color), #ff8e0d);
87 border: none; color: white; border-radius: 50px; cursor: pointer;
88 box-shadow: 0 4px 15px var(--hinana-glow); transition: transform 0.2s;
89 }
90 .btn-start:hover { transform: scale(1.05); }
91
92 .ranking-tabs { display: flex; justify-content: center; gap: 15px; margin-bottom: 15px; }
93 .rank-tab-btn {
94 background: none; border: none; font-size: 0.9rem; font-weight: bold;
95 color: var(--text-secondary); cursor: pointer; padding-bottom: 5px;
96 border-bottom: 2px solid transparent; transition: all 0.2s;
97 }
98 .rank-tab-btn:hover { color: var(--text-primary); }
99 .rank-tab-btn.active { color: var(--accent-color); border-bottom: 2px solid var(--accent-color); }
100
101 .ranking-list { width: 100%; max-width: 240px; text-align: left; display: none; }
102 .ranking-list.active { display: block; }
103
104 .ranking-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--border-color); font-size: 0.9rem; }
105 .ranking-item:last-child { border-bottom: none; }
106 .rank-num { font-weight: bold; color: var(--accent-color); width: 25px; }
107 .rank-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 10px; }
108 .rank-score { font-weight: bold; color: var(--text-primary); }
109
110 .mobile-controls {
111 display: none; width: 100%; max-width: 400px;
112 margin-top: 25px; justify-content: space-between; align-items: center; z-index: 5;
113 padding: 0 15px;
114 }
115 .d-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; justify-items: center; align-items: center; }
116 .action-pad { display: flex; gap: 15px; }
117 .btn-ctrl {
118 width: 55px; height: 55px; border-radius: 16px;
119 background-color: var(--bg-tertiary); border: 2px solid var(--border-color);
120 color: var(--text-primary); font-size: 1.4rem;
121 display: flex; align-items: center; justify-content: center;
122 cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
123 box-shadow: 0 4px 0 var(--border-color); transition: all 0.1s;
124 }
125 .btn-ctrl:active { transform: translateY(4px); box-shadow: none; background-color: var(--accent-color); color: white; border-color: var(--accent-color); }
126 .btn-big { width: 65px; height: 65px; font-size: 1.6rem; background-color: var(--accent-color); color: white; border: none; box-shadow: 0 4px 0 #964305; }
127 .btn-big:active { box-shadow: none; background-color: #d65f0a; }
128 .btn-hold { background-color: #6c757d; color: white; border: none; box-shadow: 0 4px 0 #495057; font-size: 1.2rem; }
129 .btn-hold:active { background-color: #5a6268; box-shadow: none; }
130 .grid-empty { visibility: hidden; }
131
132 .header-brand { display: flex; align-items: center; gap: 15px; }
133 .header-logo { height: 28px; width: auto; mix-blend-mode: multiply; }
134 body.dark-mode .header-logo { mix-blend-mode: screen; }
135 .header-nav { position: absolute; left: 50%; transform: translateX(-50%); display: flex; gap: 20px; align-items: center; z-index: 5; }
136 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); }
137 .nav-link:hover { color: var(--accent-color); }
138 .nav-link.active { color: var(--text-primary); }
139 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
140 .login-link { color: var(--accent-color); font-weight: bold; }
141 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
142 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; }
143 .icon-btn:hover { color: var(--text-primary); }
144
145 .info-card { background-color: var(--bg-main); border-radius: 12px; padding: 20px; border: 1px solid var(--border-color); box-shadow: 0 1px 2px 0 rgba(60, 50, 40, 0.08); margin-bottom: 20px; }
146 .info-card-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 10px; }
147 .footer { text-align: center; margin-top: 20px; color: var(--text-secondary); font-size: 0.8rem; }
148 .site-footer {
149 width: min(1180px, calc(100% - 36px));
150 margin: 0 auto 38px;
151 padding-top: 26px;
152 border-top: 1px solid var(--border-color);
153 color: var(--text-secondary);
154 display: flex; align-items: center; justify-content: space-between; gap: 20px;
155 font-size: 0.78rem;
156 }
157 .footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
158 .footer-logo { width: 44px; height: 44px; object-fit: contain; opacity: 0.76; mix-blend-mode: multiply; }
159 body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.9; }
160 .footer-title { color: var(--text-primary); font-weight: 950; font-size: 0.9rem; }
161 .footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
162 .footer-links a { color: var(--text-secondary); font-weight: 850; text-decoration: none; }
163 .footer-links a:hover { color: var(--accent-color); }
164 .footer-copy { margin-top: 4px; text-align: right; font-size: 0.72rem; }
165 .footer-version {
166 display: inline-flex; align-items: center; min-height: 20px;
167 padding: 0 8px; margin-right: 8px; border: 1px solid var(--border-color);
168 border-radius: 999px; color: var(--accent-color); font-weight: 850;
169 background: var(--bg-secondary);
170 }
171
172 @media (max-width: 960px) {
173 html, body { overflow: auto !important; height: auto !important; touch-action: auto !important; }
174 .layout-container { flex-direction: column; height: auto !important; }
175 .content-column { width: 100%; height: auto !important; border: none; order: 1; overflow: visible; padding: 10px; }
176
177 .shelf-column {
178 display: flex; order: 2; width: 100%; max-width: 520px;
179 margin: 0 auto 20px auto; background-color: transparent;
180 border-right: none; border-bottom: 1px solid var(--border-color); padding-bottom: 20px;
181 }
182 .shelf-column > div { width: 100% !important; }
183 .ranking-list { max-width: 100%; }
184
185 .info-column {
186 order: 3; width: 100%; max-width: 520px; margin: 0 auto 30px auto;
187 height: auto; border-left: none; border-top: none; padding: 20px;
188 }
189
190 canvas.main-canvas { max-height: 50vh; width: auto; box-shadow: 0 5px 20px var(--hinana-glow); }
191 .mobile-controls { display: flex; }
192 .game-board-wrapper { gap: 8px; }
193 .panel-box { width: 70px; padding: 5px; }
194 canvas.mini-canvas { width: 60px; height: 60px; }
195
196 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
197 .header-nav { position: static; transform: none; width: 100%; justify-content: center; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); order: 3; }
198 .header-brand { flex: 1; order: 1; }
199 .header-controls { flex: auto; justify-content: flex-end; order: 2; }
200 .site-footer { flex-direction: column; align-items: flex-start; width: min(100% - 24px, 1180px); }
201 .footer-links { justify-content: flex-start; }
202 .footer-copy { text-align: left; }
203 }
204 .d-none { display: none !important; }
205
206 body.game-active { overflow: hidden !important; height: 100vh !important; height: 100dvh !important; touch-action: none !important; }
207 body.game-active .global-header, body.game-active .shelf-column, body.game-active .info-column, body.game-active footer, body.game-active .footer { display: none !important; }
208 body.game-active .layout-container { height: 100% !important; margin: 0; padding: 0; flex-direction: column; }
209 body.game-active .content-column { width: 100%; height: 100% !important; justify-content: flex-start; padding-top: 20px; }
210 body.game-active canvas.main-canvas { max-height: 60vh; }
211 body.game-active .game-info { position: relative; top: auto; margin-bottom: 10px; z-index: 20; }
212 body.game-active .game-info-inner { justify-content: center; gap: 20px; align-items: center; }
213 body.game-active .score-box { font-size: 2.2rem; margin: 0; }
214 body.game-active .level-box { font-size: 1.5rem; margin: 0; }
215 body.game-active .timer-box { font-size: 1.2rem; margin: 0; }
216
217 a.btn-guest-login,
218 a.btn-guest-login:link,
219 a.btn-guest-login:visited,
220 a.btn-guest-login:hover,
221 a.btn-guest-login:active,
222 a.btn-guest-login i {
223 background-color: #ffffff !important;
224 color: #000000 !important;
225 border: none !important;
226 font-weight: 800 !important;
227 transition: transform 0.2s;
228 opacity: 1 !important;
229 }
230
231 a.btn-guest-login:hover {
232 transform: scale(1.05);
233 background-color: #f0f0f0 !important;
234 }
235 </style>
236 </head>
237
238 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
239
240 <header class="global-header">
241 <div class="header-brand">
242 <a href="/hinana/index">
243 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>" alt="Hinana Archive" class="header-logo">
244 </a>
245 </div>
246 <nav class="header-nav">
247 <a href="/hinana/index" class="nav-link">Archive</a>
248 <a href="/hinana/info" class="nav-link active">Info</a>
249 <a href="/hinana/blog" class="nav-link">Blog</a>
250 <a href="/hinana/lounge" class="nav-link">Lounge</a>
251 <span class="nav-divider">|</span>
252 <% if(username) { %>
253 <a href="/logout?redirect=/hinana/info" class="nav-link text-danger fw-bold">Logout</a>
254 <% } else { %>
255 <a href="/login?redirect=/hinana/info" class="nav-link login-link fw-bold">Login</a>
256 <% } %>
257 </nav>
258 <div class="header-controls">
259 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
260 <form action="/toggle-theme" method="POST" class="d-inline">
261 <button type="submit" class="icon-btn" title="테마 변경">
262 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
263 </button>
264 </form>
265 </div>
266 </header>
267
268 <div class="layout-container">
269 <div class="shelf-column">
270 <div class="text-center p-3 rounded w-75" style="background: var(--bg-main); border:1px solid var(--border-color);">
271 <h6 class="mb-3 fw-bold text-accent"><i class="bi bi-trophy-fill"></i> LEADERBOARD</h6>
272 <div class="ranking-tabs">
273 <button class="rank-tab-btn active" onclick="switchRank('hinana')">히나나 스코어</button>
274 <button class="rank-tab-btn" onclick="switchRank('pure')">총합점수</button>
275 </div>
276 <div id="rank-hinana" class="ranking-list active">
277 <% if (typeof leaderboard !== 'undefined' && leaderboard.length > 0) { %>
278 <% leaderboard.forEach((entry, index) => { %>
279 <div class="ranking-item">
280 <span class="rank-num"><%= index + 1 %></span>
281 <span class="rank-name"><%= entry.username %></span>
282 <span class="rank-score"><%= entry.hinanaScore.toLocaleString() %></span>
283 </div>
284 <% }); %>
285 <% } else { %>
286 <div class="text-center text-secondary small py-3">기록 없음</div>
287 <% } %>
288 </div>
289 <div id="rank-pure" class="ranking-list">
290 <% if (typeof pureLeaderboard !== 'undefined' && pureLeaderboard.length > 0) { %>
291 <% pureLeaderboard.forEach((entry, index) => { %>
292 <div class="ranking-item">
293 <span class="rank-num"><%= index + 1 %></span>
294 <span class="rank-name"><%= entry.username %></span>
295 <span class="rank-score"><%= entry.rawScore.toLocaleString() %></span>
296 </div>
297 <% }); %>
298 <% } else { %>
299 <div class="text-center text-secondary small py-3">기록 없음</div>
300 <% } %>
301 </div>
302 </div>
303 </div>
304
305 <div class="content-column">
306 <div class="game-info">
307 <div class="game-info-inner">
308 <div class="level-box" id="level">LV 1</div>
309 <div class="score-box" id="score">0</div>
310 <div class="timer-box" id="timer">00:00</div>
311 </div>
312 </div>
313
314 <div class="game-board-wrapper">
315 <div class="side-panel">
316 <div class="panel-box">
317 <div class="panel-title">HOLD</div>
318 <canvas id="hold" width="80" height="80" class="mini-canvas"></canvas>
319 </div>
320 </div>
321
322 <canvas id="tetris" width="200" height="400" class="main-canvas"></canvas>
323
324 <div class="side-panel">
325 <div class="panel-box">
326 <div class="panel-title">NEXT</div>
327 <canvas id="next" width="80" height="80" class="mini-canvas"></canvas>
328 </div>
329 </div>
330 </div>
331
332 <div class="mobile-controls">
333 <div class="d-pad">
334 <button class="btn-ctrl btn-hold" onpointerdown="triggerInput(16); event.preventDefault();"><i class="bi bi-arrow-repeat"></i></button>
335 <button class="btn-ctrl" onpointerdown="triggerInput(38); event.preventDefault();"><i class="bi bi-arrow-clockwise"></i></button>
336 <div class="grid-empty"></div>
337 <button class="btn-ctrl" onpointerdown="startMove(37); event.preventDefault();" onpointerup="stopMove(37)" onpointerleave="stopMove(37)"><i class="bi bi-caret-left-fill"></i></button>
338 <button class="btn-ctrl" onpointerdown="startMove(40); event.preventDefault();" onpointerup="stopMove(40)" onpointerleave="stopMove(40)"><i class="bi bi-caret-down-fill"></i></button>
339 <button class="btn-ctrl" onpointerdown="startMove(39); event.preventDefault();" onpointerup="stopMove(39)" onpointerleave="stopMove(39)"><i class="bi bi-caret-right-fill"></i></button>
340 </div>
341 <div class="action-pad">
342 <button class="btn-ctrl btn-big" onpointerdown="triggerInput(32); event.preventDefault();"><i class="bi bi-arrow-bar-down"></i></button>
343 </div>
344 </div>
345
346 <div class="start-overlay" id="overlay">
347 <div class="mb-2" style="font-size:3rem; font-weight:800; color:var(--accent-color); text-shadow: 2px 2px 0 #fff;">TETRIS</div>
348
349 <div class="mb-4 text-center">
350 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
351 <div class="text-warning mb-2 small fw-bold"><i class="bi bi-exclamation-triangle-fill"></i> 게스트로 플레이 중입니다</div>
352
353 <a href="/login?redirect=/hinana/tetris" class="btn btn-sm btn-guest-login rounded-pill px-4 shadow-sm">
354 <i class="bi bi-box-arrow-in-right"></i> 로그인하기
355 </a>
356
357 <% } else { %>
358 <div class="text-white mb-1">Player: <span class="fw-bold" style="color:var(--accent-color)"><%= username %></span></div>
359 <a href="/logout?redirect=/hinana/tetris" class="btn btn-sm btn-outline-light rounded-pill px-3" style="font-size: 0.8rem; opacity: 0.8;">
360 로그아웃
361 </a>
362 <% } %>
363 </div>
364 <div id="result-area" class="mb-4 text-center d-none">
365 <div class="h4 text-white">GAME OVER</div>
366 <div class="small text-light">Hinana Score</div>
367 <div class="h2 fw-bold text-warning" id="final-score">0</div>
368 </div>
369 <div id="bookmark-claim-area" class="mb-3 text-center d-none" style="background: rgba(255,255,255,0.1); border: 1px solid var(--accent-color); border-radius: 12px; padding: 15px 20px; max-width: 320px;">
370 <div class="small text-light mb-1"><i class="bi bi-bookmark-fill" style="color: var(--accent-color);"></i> 책갈피 전환</div>
371 <div class="h5 fw-bold text-warning mb-2" id="claim-amount">0개</div>
372 <div class="small text-light mb-3">이 게임 결과로 책갈피를 전환하시겠습니까?</div>
373 <div class="d-flex justify-content-center gap-2">
374 <button class="btn btn-sm btn-warning fw-bold px-4" onclick="claimBookmarks()">예</button>
375 <button class="btn btn-sm btn-outline-light fw-bold px-4" onclick="dismissClaim()">아니오</button>
376 </div>
377 <div id="claim-result" class="small mt-2 d-none"></div>
378 </div>
379 <button class="btn-start" onclick="startGame()"><i class="bi bi-play-fill"></i> GAME START</button>
380 </div>
381 </div>
382
383 <div class="info-column">
384 <div class="p-3 border rounded shadow-sm" style="background-color:var(--bg-main)!important; border-color:var(--border-color)!important;">
385 <small class="text-secondary fw-bold text-uppercase"><i class="bi bi-person-circle"></i> Player</small>
386 <div class="fw-bold fs-5 mb-3 text-truncate"><% if(username) { %><a href="/hinana/userInfo" style="color: inherit;"><%= username %></a><% } else { %>Guest<% } %></div>
387 <hr style="opacity:0.2">
388 <small class="text-secondary fw-bold text-uppercase"><i class="bi bi-palette"></i> Theme</small>
389 <form action="/toggle-theme" method="POST">
390 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
391 <button class="btn btn-sm btn-outline-secondary w-100 mt-2 fw-bold">
392 <%= theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode' %>
393 </button>
394 </form>8.0.3.2
395 </div>
396 <div class="info-card mt-3" style="margin-top: 20px !important;">
397 <div class="info-card-title">System Info</div>
398 <ul class="small text-secondary list-unstyled mb-0">
399 <li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
400 <li class="mb-1 d-flex justify-content-between"><span> Ver. 7.0.2.0-Utsumi Aoba</span></li>
401 </ul>
402 </div>
403
404 </div>
405 </div>
406
407 <footer class="site-footer">
408 <div class="footer-brand">
409 <img src="/image/sign.png" alt="비나래" class="footer-logo">
410 <div>
411 <div class="footer-title">비나래 라운지</div>
412 <div>Archive My Lounge</div>
413 </div>
414 </div>
415 <div>
416 <div class="footer-links">
417 <a href="/hinana/index">Archive</a>
418 <a href="/hinana/lounge">Lounge</a>
419 <a href="/hinana/gallery">Gallery</a>
420 <a href="/hinana/plaza">Plaza</a>
421 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
422 </div>
423 <div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
424 </div>
425 </footer>
426
427 <script>
428 const canvas = document.getElementById('tetris');
429 const context = canvas.getContext('2d');
430 const holdCanvas = document.getElementById('hold');
431 const holdCtx = holdCanvas.getContext('2d');
432 const nextCanvas = document.getElementById('next');
433 const nextCtx = nextCanvas.getContext('2d');
434
435 const scoreElement = document.getElementById('score');
436 const timerElement = document.getElementById('timer');
437 const levelElement = document.getElementById('level');
438 const overlay = document.getElementById('overlay');
439 const resultArea = document.getElementById('result-area');
440 const finalScoreElement = document.getElementById('final-score');
441 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
442
443 const BLOCK_SIZE = 20;
444 const MINI_BLOCK_SIZE = 20;
445
446 context.scale(BLOCK_SIZE, BLOCK_SIZE);
447 holdCtx.scale(MINI_BLOCK_SIZE, MINI_BLOCK_SIZE);
448 nextCtx.scale(MINI_BLOCK_SIZE, MINI_BLOCK_SIZE);
449
450 const colors = [ null, '#00FFFF', '#FF7F00', '#0000FF', '#FFFF00', '#FF0000', '#00FF00', '#800080' ];
451
452 // [★수정] SRS 표준 월 킥 데이터 (Wall Kick Data)
453 const JLSTZ_WALL_KICKS = {
454 '0-1': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
455 '1-0': [[0,0], [1,0], [1,1], [0,-2], [1,-2]],
456 '1-2': [[0,0], [1,0], [1,-1], [0,2], [1,2]],
457 '2-1': [[0,0], [-1,0], [-1,1], [0,-2], [-1,-2]],
458 '2-3': [[0,0], [1,0], [1,1], [0,-2], [1,-2]],
459 '3-2': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
460 '3-0': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
461 '0-3': [[0,0], [1,0], [1,1], [0,-2], [1,-2]]
462 };
463
464 const I_WALL_KICKS = {
465 '0-1': [[0,0], [-2,0], [1,0], [-2,-1], [1,2]],
466 '1-0': [[0,0], [2,0], [-1,0], [2,1], [-1,-2]],
467 '1-2': [[0,0], [-1,0], [2,0], [-1,2], [2,-1]],
468 '2-1': [[0,0], [1,0], [-2,0], [1,-2], [-2,1]],
469 '2-3': [[0,0], [2,0], [-1,0], [2,1], [-1,-2]],
470 '3-2': [[0,0], [-2,0], [1,0], [-2,-1], [1,2]],
471 '3-0': [[0,0], [1,0], [-2,0], [1,-2], [-2,1]],
472 '0-3': [[0,0], [-1,0], [2,0], [-1,2], [2,-1]]
473 };
474
475 let effects = [];
476 let shake = 0;
477 let lockResets = 0;
478 const LOCK_RESET_LIMIT = 15;
479 let stats = { startTime: 0, playTime: 0, totalLines: 0, tetrisLines: 0, tspinLines: 0 };
480 const keyState = {};
481 const keyTimers = {};
482 const DAS_DELAY = 130;
483 const ARR_DELAY = 30;
484
485 function switchRank(type) {
486 document.querySelectorAll('.rank-tab-btn').forEach(btn => btn.classList.remove('active'));
487 event.target.classList.add('active');
488 document.querySelectorAll('.ranking-list').forEach(list => list.classList.remove('active'));
489 document.getElementById('rank-' + type).classList.add('active');
490 }
491
492 function drawGrid() {
493 context.lineWidth = 0.05;
494 context.strokeStyle = 'rgba(255, 255, 255, 0.15)';
495 context.beginPath();
496 for (let x = 1; x < 10; x++) { context.moveTo(x, 0); context.lineTo(x, 20); }
497 for (let y = 1; y < 20; y++) { context.moveTo(0, y); context.lineTo(10, y); }
498 context.stroke();
499 context.closePath();
500 }
501
502 // [★수정] SRS 표준 형태 (SRS Spawn Orientations)
503 function createPiece(type) {
504 if (type === 'I') return [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]]; // Cyan
505 if (type === 'L') return [[0,0,2], [2,2,2], [0,0,0]]; // Orange
506 if (type === 'J') return [[3,0,0], [3,3,3], [0,0,0]]; // Blue
507 if (type === 'O') return [[4,4], [4,4]]; // Yellow
508 if (type === 'Z') return [[5,5,0], [0,5,5], [0,0,0]]; // Red
509 if (type === 'S') return [[0,6,6], [6,6,0], [0,0,0]]; // Green
510 if (type === 'T') return [[0,7,0], [7,7,7], [0,0,0]]; // Purple
511 }
512
513 const arena = createMatrix(10, 20);
514
515 const player = {
516 pos: {x: 0, y: 0},
517 matrix: null,
518 score: 0,
519 level: 1,
520 holdType: null,
521 nextType: null,
522 canHold: true,
523 currentType: null,
524 rotation: 0, // [★추가] 회전 상태 (0, 1, 2, 3)
525 lastMoveRotate: false
526 };
527
528 let bag = [];
529 function getNextPieceType() {
530 if (bag.length === 0) {
531 bag = 'ILJOTSZ'.split('');
532 for (let i = bag.length - 1; i > 0; i--) {
533 const j = Math.floor(Math.random() * (i + 1));
534 [bag[i], bag[j]] = [bag[j], bag[i]];
535 }
536 }
537 return bag.pop();
538 }
539
540 function createMatrix(w, h) {
541 const matrix = [];
542 while (h--) matrix.push(new Array(w).fill(0));
543 return matrix;
544 }
545
546 function draw() {
547 context.save();
548 context.setTransform(1, 0, 0, 1, 0, 0);
549 context.clearRect(0, 0, canvas.width, canvas.height);
550 context.restore();
551
552 context.save();
553 if (shake > 0) {
554 const dx = (Math.random() - 0.5) * shake * 0.1;
555 const dy = (Math.random() - 0.5) * shake * 0.1;
556 context.translate(dx, dy);
557 shake *= 0.9;
558 if (shake < 0.5) shake = 0;
559 }
560
561 drawGrid();
562 drawMatrix(arena, {x: 0, y: 0}, context);
563
564 // [★수정] 고스트(섀도우) 가독성 향상 적용됨
565 if (player.matrix) {
566 const ghost = { pos: { ...player.pos }, matrix: player.matrix };
567 while (!collide(arena, ghost)) { ghost.pos.y++; }
568 ghost.pos.y--;
569
570 ghost.matrix.forEach((row, y) => {
571 row.forEach((value, x) => {
572 if (value !== 0) {
573 const finalX = x + ghost.pos.x;
574 const finalY = y + ghost.pos.y;
575
576 // 1. 내부 옅게
577 context.fillStyle = colors[value];
578 context.globalAlpha = 0.3;
579 context.fillRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
580
581 // 2. 테두리 진하게
582 context.globalAlpha = 0.8;
583 context.strokeStyle = colors[value];
584 context.lineWidth = 0.1;
585 context.strokeRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
586 }
587 });
588 });
589 context.globalAlpha = 1.0;
590 }
591
592 drawMatrix(player.matrix, player.pos, context);
593
594 effects.forEach((effect, index) => {
595 context.globalAlpha = effect.life;
596 context.fillStyle = effect.color;
597 context.font = "bold 1px sans-serif";
598 context.textAlign = "center";
599 context.fillText(effect.text, effect.x, effect.y);
600 context.lineWidth = 0.05;
601 context.strokeStyle = 'black';
602 context.strokeText(effect.text, effect.x, effect.y);
603 effect.y -= 0.05;
604 effect.life -= 0.02;
605 if (effect.life <= 0) effects.splice(index, 1);
606 });
607 context.restore();
608
609 drawMiniPanel(holdCtx, player.holdType);
610 drawMiniPanel(nextCtx, player.nextType);
611 }
612
613 function drawMiniPanel(ctx, type) {
614 ctx.clearRect(0, 0, 4, 4);
615 if (!type) return;
616 const piece = createPiece(type);
617 const offsetX = (4 - piece[0].length) / 2;
618 const offsetY = (4 - piece.length) / 2;
619 drawMatrix(piece, {x: offsetX, y: offsetY}, ctx);
620 }
621
622 function drawMatrix(matrix, offset, ctx) {
623 matrix.forEach((row, y) => {
624 row.forEach((value, x) => {
625 if (value !== 0) {
626 const finalX = x + offset.x;
627 const finalY = y + offset.y;
628 const color = colors[value];
629 ctx.fillStyle = color;
630 ctx.fillRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
631 ctx.lineWidth = 0.1;
632 ctx.strokeStyle = 'rgba(255,255,255,0.6)';
633 ctx.beginPath();
634 ctx.moveTo(finalX, finalY + 1); ctx.lineTo(finalX, finalY); ctx.lineTo(finalX + 1, finalY);
635 ctx.stroke();
636 ctx.strokeStyle = 'rgba(0,0,0,0.4)';
637 ctx.beginPath();
638 ctx.moveTo(finalX + 1, finalY); ctx.lineTo(finalX + 1, finalY + 1); ctx.lineTo(finalX, finalY + 1);
639 ctx.stroke();
640 }
641 });
642 });
643 }
644
645 function merge(arena, player) {
646 player.matrix.forEach((row, y) => {
647 row.forEach((value, x) => {
648 if (value !== 0) {
649 arena[y + player.pos.y][x + player.pos.x] = value;
650 }
651 });
652 });
653 }
654
655 // [★수정] 순수 매트릭스 회전 (시계/반시계)
656 function rotate(matrix, dir) {
657 for (let y = 0; y < matrix.length; ++y) {
658 for (let x = 0; x < y; ++x) {
659 [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
660 }
661 }
662 if (dir > 0) matrix.forEach(row => row.reverse());
663 else matrix.reverse();
664 }
665
666 function playerReset() {
667 if (player.nextType === null) {
668 player.nextType = getNextPieceType();
669 }
670
671 player.currentType = player.nextType;
672 player.matrix = createPiece(player.currentType);
673 player.nextType = getNextPieceType();
674
675 player.pos.y = 0;
676 // SRS는 3x3일 경우 (3,0)에서 시작, 4x4일 경우 (3,0) or (3,-1)
677 player.pos.x = Math.floor((arena[0].length - player.matrix[0].length) / 2);
678 player.rotation = 0; // 초기화
679 player.canHold = true;
680 lockTimer = 0;
681 lockResets = 0;
682 player.lastMoveRotate = false;
683
684 if (collide(arena, player)) {
685 gameOver();
686 }
687 }
688
689 const loggedInUsername = '<%= username || "" %>';
690 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
691 let lastHinanaScore = 0;
692
693 function sat(x, cap) {
694 return cap * (1 - Math.exp(-x / cap));
695 }
696
697 function calcSkillHinanaScore(timeSec, lines, lines4, tspinLines) {
698 const t = Math.max(timeSec, 90);
699 const lpm = (lines * 60) / t;
700 const tpm = ((lines4 / 4) * 60) / t;
701 const tspm = (tspinLines * 60) / t;
702 return Math.round(1000 * sat(lpm, 35) + 400 * sat(tpm, 6) + 500 * sat(tspm, 8));
703 }
704
705 function gameOver() {
706 isPaused = true;
707 document.body.classList.remove('game-active');
708
709 resultArea.classList.remove('d-none');
710 const hinanaScore = calcSkillHinanaScore(stats.playTime, stats.totalLines, stats.tetrisLines, stats.tspinLines);
711 lastHinanaScore = hinanaScore;
712 finalScoreElement.innerText = hinanaScore.toLocaleString();
713
714 // 책갈피 전환 영역 초기화
715 const claimArea = document.getElementById('bookmark-claim-area');
716 const claimResult = document.getElementById('claim-result');
717 claimArea.classList.add('d-none');
718 claimResult.classList.add('d-none');
719
720 // Guest는 서버에 제출하지 않음
721 if (!loggedInUsername) {
722 let warningMsg = document.getElementById('score-warning');
723 if (!warningMsg) {
724 warningMsg = document.createElement('div');
725 warningMsg.id = 'score-warning';
726 warningMsg.className = 'small text-danger fw-bold mt-2';
727 resultArea.appendChild(warningMsg);
728 }
729 warningMsg.innerText = "※ 로그인 후 플레이하면 랭킹에 등록됩니다.";
730 overlay.style.display = 'flex';
731 return;
732 }
733
734 fetch('/hinana/tetris/score', {
735 method: 'POST',
736 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
737 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
738 score: player.score,
739 time: stats.playTime,
740 lines: stats.totalLines,
741 lines4: stats.tetrisLines,
742 tspinLines: stats.tspinLines
743 })
744 })
745 .then(res => res.json())
746 .then(data => {
747 if (data.success === false) {
748 let warningMsg = document.getElementById('score-warning');
749 if (!warningMsg) {
750 warningMsg = document.createElement('div');
751 warningMsg.id = 'score-warning';
752 warningMsg.className = 'small text-danger fw-bold mt-2';
753 resultArea.appendChild(warningMsg);
754 }
755 warningMsg.innerText = "※ " + (data.message || "랭킹에 반영되지 않았습니다.");
756 } else {
757 const warningMsg = document.getElementById('score-warning');
758 if (warningMsg) warningMsg.remove();
759
760 // 책갈피 전환 가능 여부 확인
761 const claimable = Math.min(Math.floor(hinanaScore / 10000), 10);
762 if (canClaimTetris && claimable > 0) {
763 document.getElementById('claim-amount').innerText = claimable + '개';
764 claimArea.classList.remove('d-none');
765 }
766 }
767 overlay.style.display = 'flex';
768 })
769 .catch(err => {
770 console.error("Score submit error:", err);
771 overlay.style.display = 'flex';
772 });
773 }
774
775 function claimBookmarks() {
776 fetch('/hinana/tetris/claim-bookmarks', {
777 method: 'POST',
778 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
779 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
780 })
781 .then(res => res.json())
782 .then(data => {
783 const claimResult = document.getElementById('claim-result');
784 claimResult.classList.remove('d-none');
785 if (data.success) {
786 claimResult.className = 'small mt-2 text-warning fw-bold';
787 claimResult.innerText = '책갈피 ' + data.awarded + '개가 적립되었습니다! (총 ' + data.total + '개)';
788 canClaimTetris = false; // 오늘 전환 완료
789 } else {
790 claimResult.className = 'small mt-2 text-danger fw-bold';
791 claimResult.innerText = data.message || '전환에 실패했습니다.';
792 }
793 // 버튼 숨기기
794 document.querySelectorAll('#bookmark-claim-area button').forEach(btn => btn.style.display = 'none');
795 })
796 .catch(err => {
797 console.error("Claim error:", err);
798 });
799 }
800
801 function dismissClaim() {
802 document.getElementById('bookmark-claim-area').classList.add('d-none');
803 }
804
805 function playerHold() {
806 if (isPaused || !player.canHold) return;
807
808 if (player.holdType === null) {
809 player.holdType = player.currentType;
810 playerReset();
811 } else {
812 const temp = player.currentType;
813 player.currentType = player.holdType;
814 player.holdType = temp;
815 player.matrix = createPiece(player.currentType);
816 player.pos.y = 0;
817 player.pos.x = Math.floor((arena[0].length - player.matrix[0].length) / 2);
818 player.rotation = 0; // 홀드 시 회전 초기화
819 }
820 player.canHold = false;
821 lockTimer = 0;
822 lockResets = 0;
823 player.lastMoveRotate = false;
824 }
825
826 function playerHardDrop() {
827 if (isPaused) return;
828 while (!collide(arena, player)) { player.pos.y++; }
829 player.pos.y--;
830 merge(arena, player);
831 playerReset();
832 arenaSweep();
833 updateScore();
834 dropCounter = 0;
835 shake = 3;
836 player.lastMoveRotate = false;
837 }
838
839 function playerDrop() {
840 if (isPaused) return;
841 player.pos.y++;
842 if (collide(arena, player)) {
843 player.pos.y--;
844 return true;
845 }
846 player.lastMoveRotate = false;
847 return false;
848 }
849
850 function playerMove(dir) {
851 if (isPaused) return;
852 player.pos.x += dir;
853 if (collide(arena, player)) {
854 player.pos.x -= dir;
855 } else {
856 if (isGrounded() && lockResets < LOCK_RESET_LIMIT) { lockTimer = 0; lockResets++; }
857 player.lastMoveRotate = false;
858 }
859 }
860
861 // [★수정] SRS 기반 회전 로직 (Super Rotation System)
862 function playerRotate(dir) {
863 if (isPaused) return;
864
865 const originalPos = player.pos.x;
866 const originalY = player.pos.y;
867 const originalMat = player.matrix.map(row => [...row]); // 백업
868 const oldRotation = player.rotation;
869
870 // 1. 매트릭스 회전
871 rotate(player.matrix, dir);
872
873 // 2. 새 회전 상태 계산 (0->1->2->3 or 0->3->2->1)
874 let newRotation = (oldRotation + dir);
875 if (newRotation < 0) newRotation = 3;
876 if (newRotation > 3) newRotation = 0;
877
878 // 3. 킥 테이블 조회
879 const kickKey = oldRotation + '-' + newRotation;
880 let kicks;
881
882 if (player.currentType === 'I') {
883 kicks = I_WALL_KICKS[kickKey];
884 } else if (player.currentType === 'O') {
885 kicks = [[0,0]]; // O블록은 킥 없음
886 } else {
887 kicks = JLSTZ_WALL_KICKS[kickKey];
888 }
889
890 // 4. 테스트 (Wall Kick Test)
891 let rotated = false;
892 for (let i = 0; i < kicks.length; i++) {
893 const [ox, oy] = kicks[i];
894 player.pos.x += ox;
895 player.pos.y -= oy; // Y축은 위가 -방향이므로 뺌
896
897 if (!collide(arena, player)) {
898 rotated = true;
899 player.rotation = newRotation;
900 player.lastMoveRotate = true; // 스핀 판정용 플래그
901 if (isGrounded() && lockResets < LOCK_RESET_LIMIT) { lockTimer = 0; lockResets++; }
902 break;
903 } else {
904 // 실패 시 원복하고 다음 테스트
905 player.pos.x -= ox;
906 player.pos.y += oy;
907 }
908 }
909
910 // 5. 모든 킥 실패 시 회전 취소
911 if (!rotated) {
912 player.matrix = originalMat;
913 player.pos.x = originalPos;
914 player.pos.y = originalY;
915 }
916 }
917
918 function isGrounded() {
919 player.pos.y++;
920 let grounded = collide(arena, player);
921 player.pos.y--;
922 return grounded;
923 }
924
925 // [★수정] All-Spin 감지 (T-Spin 포함)
926 function checkSpin() {
927 if (!player.lastMoveRotate) return null; // 회전 안했으면 무효
928 if (player.currentType !== 'T') return null; // T-Spin만 인정
929
930 let corners = 0;
931 // 현재 블록의 중심 기준이 아니라, 3x3 박스 내의 네 귀퉁이 검사
932 // SRS에서 T블록 중심은 (1,1)
933 const checkPoints = [[0, 0], [2, 0], [0, 2], [2, 2]];
934
935 checkPoints.forEach(([kx, ky]) => {
936 const x = player.pos.x + kx;
937 const y = player.pos.y + ky;
938 if (arena[y] === undefined || arena[y][x] === undefined || arena[y][x] !== 0) {
939 corners++;
940 }
941 });
942
943 if (corners >= 3) {
944 return player.currentType; // 'T', 'S', 'Z' 등 반환
945 }
946 return null;
947 }
948
949 function collide(arena, player) {
950 const [m, o] = [player.matrix, player.pos];
951 for (let y = 0; y < m.length; ++y) {
952 for (let x = 0; x < m[y].length; ++x) {
953 if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) {
954 return true;
955 }
956 }
957 }
958 return false;
959 }
960
961 function arenaSweep() {
962 let rowCount = 0;
963 outer: for (let y = arena.length - 1; y > 0; --y) {
964 for (let x = 0; x < arena[y].length; ++x) {
965 if (arena[y][x] === 0) continue outer;
966 }
967 const row = arena.splice(y, 1)[0].fill(0);
968 arena.unshift(row);
969 ++y;
970 rowCount++;
971 }
972
973 const spinType = checkSpin(); // 스핀 체크
974
975 if (rowCount > 0 || spinType) {
976 stats.totalLines += rowCount;
977 if (rowCount === 4) stats.tetrisLines += 4;
978 if (spinType && rowCount > 0) stats.tspinLines += rowCount;
979
980 let addedScore = 0;
981 let text = "";
982 let color = "#fff";
983
984 // [★수정] 점수 로직 (Spin 우선)
985 if (spinType) {
986 const name = spinType + "-SPIN";
987 color = "#D500F9";
988 if (rowCount === 0) { addedScore = 100; text = name; } // Mini or Twist (no clear)
989 else if (rowCount === 1) { addedScore = 800; text = name + " SINGLE!"; }
990 else if (rowCount === 2) { addedScore = 1200; text = name + " DOUBLE!"; }
991 else if (rowCount === 3) { addedScore = 1600; text = name + " TRIPLE!"; }
992 shake = 5;
993 } else {
994 if (rowCount === 1) { addedScore = 100; text = "+100"; }
995 else if (rowCount === 2) { addedScore = 300; text = "DOUBLE! +300"; color = "#00C7FF"; }
996 else if (rowCount === 3) { addedScore = 500; text = "TRIPLE! +500"; color = "#FF9100"; }
997 else if (rowCount === 4) { addedScore = 800; text = "TETRIS!!! +800"; color = "#D500F9"; shake = 10; }
998 }
999
1000 if (addedScore > 0) {
1001 player.score += addedScore * player.level;
1002 if(text) {
1003 effects.push({
1004 x: arena[0].length / 2, y: arena.length / 2,
1005 text: text, color: color, life: 1.0
1006 });
1007 }
1008 }
1009
1010 const newLevel = Math.floor(stats.totalLines / 5) + 1;
1011 if (newLevel > player.level) {
1012 player.level = newLevel;
1013 levelElement.innerText = "LV " + player.level;
1014 levelElement.classList.add('level-up-anim');
1015 setTimeout(() => levelElement.classList.remove('level-up-anim'), 500);
1016 dropInterval = 1000 * Math.pow(0.8, player.level - 1);
1017 effects.push({ x: arena[0].length / 2, y: arena.length / 2 - 2, text: "LEVEL UP!", color: "#FF3385", life: 1.5 });
1018 }
1019 }
1020 }
1021
1022 let dropCounter = 0;
1023 let dropInterval = 1000;
1024 let lastTime = 0;
1025 let isPaused = true;
1026 const ENABLE_HOLD_R_RESTART = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
1027
1028 let rHoldTimer = null;
1029 let rIsHolding = false;
1030
1031 const R_HOLD_MS = 650;
1032 let lockTimer = 0;
1033 const LOCK_DELAY = 500;
1034
1035 function update(time = 0) {
1036 if (isPaused) return;
1037 const deltaTime = time - lastTime;
1038 lastTime = time;
1039
1040 handleInput(deltaTime);
1041
1042 if (stats.startTime > 0) {
1043 stats.playTime = Math.floor((Date.now() - stats.startTime) / 1000);
1044 const min = String(Math.floor(stats.playTime / 60)).padStart(2, '0');
1045 const sec = String(stats.playTime % 60).padStart(2, '0');
1046 timerElement.innerText = `${min}:${sec}`;
1047 }
1048
1049 dropCounter += deltaTime;
1050 if (dropCounter > dropInterval) {
1051 player.pos.y++;
1052 if (collide(arena, player)) {
1053 player.pos.y--;
1054 } else {
1055 lockTimer = 0;
1056 player.lastMoveRotate = false;
1057 }
1058 dropCounter = 0;
1059 }
1060
1061 if (isGrounded()) {
1062 lockTimer += deltaTime;
1063 if (lockTimer > LOCK_DELAY) {
1064 merge(arena, player);
1065 playerReset();
1066 arenaSweep();
1067 updateScore();
1068 lockTimer = 0;
1069 }
1070 }
1071
1072 draw();
1073 requestAnimationFrame(update);
1074 }
1075
1076 function handleInput(dt) {
1077 [37, 39].forEach(code => {
1078 if (keyState[code]) {
1079 if (keyTimers[code] === 0) {
1080 playerMove(code === 37 ? -1 : 1);
1081 keyTimers[code] += dt;
1082 } else {
1083 keyTimers[code] += dt;
1084 if (keyTimers[code] > DAS_DELAY) {
1085 while (keyTimers[code] > DAS_DELAY + ARR_DELAY) {
1086 playerMove(code === 37 ? -1 : 1);
1087 keyTimers[code] -= ARR_DELAY;
1088 }
1089 }
1090 }
1091 } else {
1092 keyTimers[code] = 0;
1093 }
1094 });
1095
1096 if (keyState[40]) {
1097 if (keyTimers[40] === 0) {
1098 playerDrop();
1099 keyTimers[40] += dt;
1100 } else {
1101 keyTimers[40] += dt;
1102 if (keyTimers[40] > ARR_DELAY) {
1103 playerDrop();
1104 keyTimers[40] = 0;
1105 }
1106 }
1107 } else {
1108 keyTimers[40] = 0;
1109 }
1110 }
1111
1112 function startMove(code) { keyState[code] = true; }
1113 function stopMove(code) { keyState[code] = false; }
1114 function triggerInput(code) {
1115 const e = { keyCode: code, preventDefault: () => {} };
1116 document.dispatchEvent(new KeyboardEvent('keydown', e));
1117 }
1118
1119 function updateScore() {
1120 scoreElement.innerText = player.score;
1121 }
1122
1123 function startGame() {
1124 lastTime = 0; // ✅ 추가: 프레임 타이밍 초기화
1125 isPaused = true; // ✅ 추가: 혹시 돌고 있던 루프 잠깐 멈춤(중복 방지)
1126 document.body.classList.add('game-active');
1127 window.scrollTo(0, 0);
1128
1129 overlay.style.display = 'none';
1130 resultArea.classList.add('d-none');
1131
1132 bag = [];
1133
1134 arena.forEach(row => row.fill(0));
1135 player.score = 0;
1136 player.level = 1;
1137 player.holdType = null;
1138 player.nextType = null;
1139 player.lastMoveRotate = false;
1140
1141 dropInterval = 1000;
1142 lockTimer = 0;
1143 lockResets = 0;
1144
1145 stats = { startTime: Date.now(), playTime: 0, totalLines: 0, tetrisLines: 0, tspinLines: 0 };
1146 timerElement.innerText = "00:00";
1147 levelElement.innerText = "LV 1";
1148
1149 effects = [];
1150 updateScore();
1151 playerReset();
1152 isPaused = false;
1153 update();
1154 }
1155
1156 document.addEventListener('keydown', event => {
1157 if (isPaused) return;
1158
1159 if([32, 37, 38, 39, 40].includes(event.keyCode)) {
1160 event.preventDefault();
1161 }
1162
1163 if (event.keyCode === 37 || event.keyCode === 39 || event.keyCode === 40) {
1164 if (!keyState[event.keyCode]) {
1165 keyState[event.keyCode] = true;
1166 keyTimers[event.keyCode] = 0;
1167 }
1168 }
1169 else if (event.keyCode === 38) { playerRotate(1); }
1170 else if (event.keyCode === 90) { playerRotate(-1); } // Z key
1171 else if (event.keyCode === 32) { playerHardDrop(); }
1172 else if (event.keyCode === 16 || event.keyCode === 67) { playerHold(); }
1173 });
1174
1175 document.addEventListener('keyup', event => {
1176 if (event.keyCode === 37 || event.keyCode === 39 || event.keyCode === 40) {
1177 keyState[event.keyCode] = false;
1178 }
1179 });
1180
1181 document.addEventListener('keydown', (event) => {
1182 if (!ENABLE_HOLD_R_RESTART) return;
1183
1184 // R 키 (82)
1185 if (event.keyCode === 82) {
1186 event.preventDefault();
1187
1188 if (rIsHolding) return; // 키 반복 입력 방지
1189 rIsHolding = true;
1190
1191 // 일정 시간 유지되면 재시작
1192 rHoldTimer = setTimeout(() => {
1193 // 여전히 누르고 있다면 재시작
1194 if (rIsHolding) startGame();
1195 }, R_HOLD_MS);
1196 }
1197 });
1198
1199 document.addEventListener('keyup', (event) => {
1200 if (event.keyCode === 82) {
1201 rIsHolding = false;
1202 if (rHoldTimer) {
1203 clearTimeout(rHoldTimer);
1204 rHoldTimer = null;
1205 }
1206 }
1207 });
1208 </script>
1209 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
1210 </body>
1211 </html>
1212