Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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: var(--ui-on-accent, #fff); 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: var(--ui-on-accent, #fff); border-color: var(--accent-color); }
126
.btn-big { width: 65px; height: 65px; font-size: 1.6rem; background-color: var(--accent-color); color: var(--ui-on-accent, #fff); 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
<link rel="stylesheet" href="/css/site-ui.css?v=3">
237
<link rel="stylesheet" href="/css/site-app.css?v=1">
238
</head>
239
240
<body data-ui-page="tetris" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
241
242
<%- include('partials/site-header', { uiPage: 'tetris' }) %>
243
244
<div id="ui-content" tabindex="-1" role="main" class="layout-container">
245
<div class="shelf-column">
246
<div class="text-center p-3 rounded w-75" style="background: var(--bg-main); border:1px solid var(--border-color);">
247
<h6 class="mb-3 fw-bold text-accent"><i class="bi bi-trophy-fill"></i> LEADERBOARD</h6>
248
<div class="ranking-tabs">
249
<button class="rank-tab-btn active" onclick="switchRank('hinana')">히나나 스코어</button>
250
<button class="rank-tab-btn" onclick="switchRank('pure')">총합점수</button>
251
</div>
252
<div id="rank-hinana" class="ranking-list active">
253
<% if (typeof leaderboard !== 'undefined' && leaderboard.length > 0) { %>
254
<% leaderboard.forEach((entry, index) => { %>
255
<div class="ranking-item">
256
<span class="rank-num"><%= index + 1 %></span>
257
<span class="rank-name"><%= entry.username %></span>
258
<span class="rank-score"><%= entry.hinanaScore.toLocaleString() %></span>
259
</div>
260
<% }); %>
261
<% } else { %>
262
<div class="text-center text-secondary small py-3">기록 없음</div>
263
<% } %>
264
</div>
265
<div id="rank-pure" class="ranking-list">
266
<% if (typeof pureLeaderboard !== 'undefined' && pureLeaderboard.length > 0) { %>
267
<% pureLeaderboard.forEach((entry, index) => { %>
268
<div class="ranking-item">
269
<span class="rank-num"><%= index + 1 %></span>
270
<span class="rank-name"><%= entry.username %></span>
271
<span class="rank-score"><%= entry.rawScore.toLocaleString() %></span>
272
</div>
273
<% }); %>
274
<% } else { %>
275
<div class="text-center text-secondary small py-3">기록 없음</div>
276
<% } %>
277
</div>
278
</div>
279
</div>
280
281
<div class="content-column">
282
<div class="game-info">
283
<div class="game-info-inner">
284
<div class="level-box" id="level">LV 1</div>
285
<div class="score-box" id="score">0</div>
286
<div class="timer-box" id="timer">00:00</div>
287
</div>
288
</div>
289
290
<div class="game-board-wrapper">
291
<div class="side-panel">
292
<div class="panel-box">
293
<div class="panel-title">HOLD</div>
294
<canvas id="hold" width="80" height="80" class="mini-canvas"></canvas>
295
</div>
296
</div>
297
298
<canvas id="tetris" width="200" height="400" class="main-canvas"></canvas>
299
300
<div class="side-panel">
301
<div class="panel-box">
302
<div class="panel-title">NEXT</div>
303
<canvas id="next" width="80" height="80" class="mini-canvas"></canvas>
304
</div>
305
</div>
306
</div>
307
308
<div class="mobile-controls">
309
<div class="d-pad">
310
<button class="btn-ctrl btn-hold" onpointerdown="triggerInput(16); event.preventDefault();"><i class="bi bi-arrow-repeat"></i></button>
311
<button class="btn-ctrl" onpointerdown="triggerInput(38); event.preventDefault();"><i class="bi bi-arrow-clockwise"></i></button>
312
<div class="grid-empty"></div>
313
<button class="btn-ctrl" onpointerdown="startMove(37); event.preventDefault();" onpointerup="stopMove(37)" onpointerleave="stopMove(37)"><i class="bi bi-caret-left-fill"></i></button>
314
<button class="btn-ctrl" onpointerdown="startMove(40); event.preventDefault();" onpointerup="stopMove(40)" onpointerleave="stopMove(40)"><i class="bi bi-caret-down-fill"></i></button>
315
<button class="btn-ctrl" onpointerdown="startMove(39); event.preventDefault();" onpointerup="stopMove(39)" onpointerleave="stopMove(39)"><i class="bi bi-caret-right-fill"></i></button>
316
</div>
317
<div class="action-pad">
318
<button class="btn-ctrl btn-big" onpointerdown="triggerInput(32); event.preventDefault();"><i class="bi bi-arrow-bar-down"></i></button>
319
</div>
320
</div>
321
322
<div class="start-overlay" id="overlay">
323
<div class="mb-2" style="font-size:3rem; font-weight:800; color:var(--accent-color); text-shadow: 2px 2px 0 #fff;">TETRIS</div>
324
325
<div class="mb-4 text-center">
326
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
327
<div class="text-warning mb-2 small fw-bold"><i class="bi bi-exclamation-triangle-fill"></i> 게스트로 플레이 중입니다</div>
328
329
<a href="/login?redirect=/hinana/tetris" class="btn btn-sm btn-guest-login rounded-pill px-4 shadow-sm">
330
<i class="bi bi-box-arrow-in-right"></i> 로그인하기
331
</a>
332
333
<% } else { %>
334
<div class="text-white mb-1">Player: <span class="fw-bold" style="color:var(--accent-color)"><%= username %></span></div>
335
<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;">
336
로그아웃
337
</a>
338
<% } %>
339
</div>
340
<div id="result-area" class="mb-4 text-center d-none">
341
<div class="h4 text-white">GAME OVER</div>
342
<div class="small text-light">Hinana Score</div>
343
<div class="h2 fw-bold text-warning" id="final-score">0</div>
344
</div>
345
<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;">
346
<div class="small text-light mb-1"><i class="bi bi-bookmark-fill" style="color: var(--accent-color);"></i> 책갈피 전환</div>
347
<div class="h5 fw-bold text-warning mb-2" id="claim-amount">0개</div>
348
<div class="small text-light mb-3">이 게임 결과로 책갈피를 전환하시겠습니까?</div>
349
<div class="d-flex justify-content-center gap-2">
350
<button class="btn btn-sm btn-warning fw-bold px-4" onclick="claimBookmarks()">예</button>
351
<button class="btn btn-sm btn-outline-light fw-bold px-4" onclick="dismissClaim()">아니오</button>
352
</div>
353
<div id="claim-result" class="small mt-2 d-none"></div>
354
</div>
355
<button class="btn-start" onclick="startGame()"><i class="bi bi-play-fill"></i> GAME START</button>
356
</div>
357
</div>
358
359
<div class="info-column">
360
<div class="p-3 border rounded shadow-sm" style="background-color:var(--bg-main)!important; border-color:var(--border-color)!important;">
361
<small class="text-secondary fw-bold text-uppercase"><i class="bi bi-person-circle"></i> Player</small>
362
<div class="fw-bold fs-5 mb-3 text-truncate"><% if(username) { %><a href="/hinana/userInfo" style="color: inherit;"><%= username %></a><% } else { %>Guest<% } %></div>
363
<hr style="opacity:0.2">
364
<small class="text-secondary fw-bold text-uppercase"><i class="bi bi-palette"></i> Theme</small>
365
<form action="/toggle-theme" method="POST">
366
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
367
<button class="btn btn-sm btn-outline-secondary w-100 mt-2 fw-bold">
368
<%= theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode' %>
369
</button>
370
</form>8.4.3.1
371
</div>
372
<div class="info-card mt-3" style="margin-top: 20px !important;">
373
<div class="info-card-title">System Info</div>
374
<ul class="small text-secondary list-unstyled mb-0">
375
<li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
376
<li class="mb-1 d-flex justify-content-between"><span> Ver. 7.0.2.0-Utsumi Aoba</span></li>
377
</ul>
378
</div>
379
380
</div>
381
</div>
382
383
<footer class="site-footer">
384
<div class="footer-brand">
385
<img src="/image/sign.png" alt="비나래" class="footer-logo">
386
<div>
387
<div class="footer-title">비나래 라운지</div>
388
<div>Archive My Lounge</div>
389
</div>
390
</div>
391
<div>
392
<div class="footer-links">
393
<a href="/hinana/index">Archive</a>
394
<a href="/hinana/lounge">Lounge</a>
395
<a href="/hinana/gallery">Gallery</a>
396
<a href="/hinana/plaza">Plaza</a>
397
<a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
398
</div>
399
<div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
400
</div>
401
</footer>
402
403
<script>
404
const canvas = document.getElementById('tetris');
405
const context = canvas.getContext('2d');
406
const holdCanvas = document.getElementById('hold');
407
const holdCtx = holdCanvas.getContext('2d');
408
const nextCanvas = document.getElementById('next');
409
const nextCtx = nextCanvas.getContext('2d');
410
411
const scoreElement = document.getElementById('score');
412
const timerElement = document.getElementById('timer');
413
const levelElement = document.getElementById('level');
414
const overlay = document.getElementById('overlay');
415
const resultArea = document.getElementById('result-area');
416
const finalScoreElement = document.getElementById('final-score');
417
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
418
419
const BLOCK_SIZE = 20;
420
const MINI_BLOCK_SIZE = 20;
421
422
context.scale(BLOCK_SIZE, BLOCK_SIZE);
423
holdCtx.scale(MINI_BLOCK_SIZE, MINI_BLOCK_SIZE);
424
nextCtx.scale(MINI_BLOCK_SIZE, MINI_BLOCK_SIZE);
425
426
const colors = [ null, '#00FFFF', '#FF7F00', '#0000FF', '#FFFF00', '#FF0000', '#00FF00', '#800080' ];
427
428
// [★수정] SRS 표준 월 킥 데이터 (Wall Kick Data)
429
const JLSTZ_WALL_KICKS = {
430
'0-1': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
431
'1-0': [[0,0], [1,0], [1,1], [0,-2], [1,-2]],
432
'1-2': [[0,0], [1,0], [1,-1], [0,2], [1,2]],
433
'2-1': [[0,0], [-1,0], [-1,1], [0,-2], [-1,-2]],
434
'2-3': [[0,0], [1,0], [1,1], [0,-2], [1,-2]],
435
'3-2': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
436
'3-0': [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]],
437
'0-3': [[0,0], [1,0], [1,1], [0,-2], [1,-2]]
438
};
439
440
const I_WALL_KICKS = {
441
'0-1': [[0,0], [-2,0], [1,0], [-2,-1], [1,2]],
442
'1-0': [[0,0], [2,0], [-1,0], [2,1], [-1,-2]],
443
'1-2': [[0,0], [-1,0], [2,0], [-1,2], [2,-1]],
444
'2-1': [[0,0], [1,0], [-2,0], [1,-2], [-2,1]],
445
'2-3': [[0,0], [2,0], [-1,0], [2,1], [-1,-2]],
446
'3-2': [[0,0], [-2,0], [1,0], [-2,-1], [1,2]],
447
'3-0': [[0,0], [1,0], [-2,0], [1,-2], [-2,1]],
448
'0-3': [[0,0], [-1,0], [2,0], [-1,2], [2,-1]]
449
};
450
451
let effects = [];
452
let shake = 0;
453
let lockResets = 0;
454
const LOCK_RESET_LIMIT = 15;
455
let stats = { startTime: 0, playTime: 0, totalLines: 0, tetrisLines: 0, tspinLines: 0 };
456
const keyState = {};
457
const keyTimers = {};
458
const DAS_DELAY = 130;
459
const ARR_DELAY = 30;
460
461
function switchRank(type) {
462
document.querySelectorAll('.rank-tab-btn').forEach(btn => btn.classList.remove('active'));
463
event.target.classList.add('active');
464
document.querySelectorAll('.ranking-list').forEach(list => list.classList.remove('active'));
465
document.getElementById('rank-' + type).classList.add('active');
466
}
467
468
function drawGrid() {
469
context.lineWidth = 0.05;
470
context.strokeStyle = 'rgba(255, 255, 255, 0.15)';
471
context.beginPath();
472
for (let x = 1; x < 10; x++) { context.moveTo(x, 0); context.lineTo(x, 20); }
473
for (let y = 1; y < 20; y++) { context.moveTo(0, y); context.lineTo(10, y); }
474
context.stroke();
475
context.closePath();
476
}
477
478
// [★수정] SRS 표준 형태 (SRS Spawn Orientations)
479
function createPiece(type) {
480
if (type === 'I') return [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]]; // Cyan
481
if (type === 'L') return [[0,0,2], [2,2,2], [0,0,0]]; // Orange
482
if (type === 'J') return [[3,0,0], [3,3,3], [0,0,0]]; // Blue
483
if (type === 'O') return [[4,4], [4,4]]; // Yellow
484
if (type === 'Z') return [[5,5,0], [0,5,5], [0,0,0]]; // Red
485
if (type === 'S') return [[0,6,6], [6,6,0], [0,0,0]]; // Green
486
if (type === 'T') return [[0,7,0], [7,7,7], [0,0,0]]; // Purple
487
}
488
489
const arena = createMatrix(10, 20);
490
491
const player = {
492
pos: {x: 0, y: 0},
493
matrix: null,
494
score: 0,
495
level: 1,
496
holdType: null,
497
nextType: null,
498
canHold: true,
499
currentType: null,
500
rotation: 0, // [★추가] 회전 상태 (0, 1, 2, 3)
501
lastMoveRotate: false
502
};
503
504
let bag = [];
505
function getNextPieceType() {
506
if (bag.length === 0) {
507
bag = 'ILJOTSZ'.split('');
508
for (let i = bag.length - 1; i > 0; i--) {
509
const j = Math.floor(Math.random() * (i + 1));
510
[bag[i], bag[j]] = [bag[j], bag[i]];
511
}
512
}
513
return bag.pop();
514
}
515
516
function createMatrix(w, h) {
517
const matrix = [];
518
while (h--) matrix.push(new Array(w).fill(0));
519
return matrix;
520
}
521
522
function draw() {
523
context.save();
524
context.setTransform(1, 0, 0, 1, 0, 0);
525
context.clearRect(0, 0, canvas.width, canvas.height);
526
context.restore();
527
528
context.save();
529
if (shake > 0) {
530
const dx = (Math.random() - 0.5) * shake * 0.1;
531
const dy = (Math.random() - 0.5) * shake * 0.1;
532
context.translate(dx, dy);
533
shake *= 0.9;
534
if (shake < 0.5) shake = 0;
535
}
536
537
drawGrid();
538
drawMatrix(arena, {x: 0, y: 0}, context);
539
540
// [★수정] 고스트(섀도우) 가독성 향상 적용됨
541
if (player.matrix) {
542
const ghost = { pos: { ...player.pos }, matrix: player.matrix };
543
while (!collide(arena, ghost)) { ghost.pos.y++; }
544
ghost.pos.y--;
545
546
ghost.matrix.forEach((row, y) => {
547
row.forEach((value, x) => {
548
if (value !== 0) {
549
const finalX = x + ghost.pos.x;
550
const finalY = y + ghost.pos.y;
551
552
// 1. 내부 옅게
553
context.fillStyle = colors[value];
554
context.globalAlpha = 0.3;
555
context.fillRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
556
557
// 2. 테두리 진하게
558
context.globalAlpha = 0.8;
559
context.strokeStyle = colors[value];
560
context.lineWidth = 0.1;
561
context.strokeRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
562
}
563
});
564
});
565
context.globalAlpha = 1.0;
566
}
567
568
drawMatrix(player.matrix, player.pos, context);
569
570
effects.forEach((effect, index) => {
571
context.globalAlpha = effect.life;
572
context.fillStyle = effect.color;
573
context.font = "bold 1px sans-serif";
574
context.textAlign = "center";
575
context.fillText(effect.text, effect.x, effect.y);
576
context.lineWidth = 0.05;
577
context.strokeStyle = 'black';
578
context.strokeText(effect.text, effect.x, effect.y);
579
effect.y -= 0.05;
580
effect.life -= 0.02;
581
if (effect.life <= 0) effects.splice(index, 1);
582
});
583
context.restore();
584
585
drawMiniPanel(holdCtx, player.holdType);
586
drawMiniPanel(nextCtx, player.nextType);
587
}
588
589
function drawMiniPanel(ctx, type) {
590
ctx.clearRect(0, 0, 4, 4);
591
if (!type) return;
592
const piece = createPiece(type);
593
const offsetX = (4 - piece[0].length) / 2;
594
const offsetY = (4 - piece.length) / 2;
595
drawMatrix(piece, {x: offsetX, y: offsetY}, ctx);
596
}
597
598
function drawMatrix(matrix, offset, ctx) {
599
matrix.forEach((row, y) => {
600
row.forEach((value, x) => {
601
if (value !== 0) {
602
const finalX = x + offset.x;
603
const finalY = y + offset.y;
604
const color = colors[value];
605
ctx.fillStyle = color;
606
ctx.fillRect(finalX + 0.05, finalY + 0.05, 0.9, 0.9);
607
ctx.lineWidth = 0.1;
608
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
609
ctx.beginPath();
610
ctx.moveTo(finalX, finalY + 1); ctx.lineTo(finalX, finalY); ctx.lineTo(finalX + 1, finalY);
611
ctx.stroke();
612
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
613
ctx.beginPath();
614
ctx.moveTo(finalX + 1, finalY); ctx.lineTo(finalX + 1, finalY + 1); ctx.lineTo(finalX, finalY + 1);
615
ctx.stroke();
616
}
617
});
618
});
619
}
620
621
function merge(arena, player) {
622
player.matrix.forEach((row, y) => {
623
row.forEach((value, x) => {
624
if (value !== 0) {
625
arena[y + player.pos.y][x + player.pos.x] = value;
626
}
627
});
628
});
629
}
630
631
// [★수정] 순수 매트릭스 회전 (시계/반시계)
632
function rotate(matrix, dir) {
633
for (let y = 0; y < matrix.length; ++y) {
634
for (let x = 0; x < y; ++x) {
635
[matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
636
}
637
}
638
if (dir > 0) matrix.forEach(row => row.reverse());
639
else matrix.reverse();
640
}
641
642
function playerReset() {
643
if (player.nextType === null) {
644
player.nextType = getNextPieceType();
645
}
646
647
player.currentType = player.nextType;
648
player.matrix = createPiece(player.currentType);
649
player.nextType = getNextPieceType();
650
651
player.pos.y = 0;
652
// SRS는 3x3일 경우 (3,0)에서 시작, 4x4일 경우 (3,0) or (3,-1)
653
player.pos.x = Math.floor((arena[0].length - player.matrix[0].length) / 2);
654
player.rotation = 0; // 초기화
655
player.canHold = true;
656
lockTimer = 0;
657
lockResets = 0;
658
player.lastMoveRotate = false;
659
660
if (collide(arena, player)) {
661
gameOver();
662
}
663
}
664
665
const loggedInUsername = '<%= username || "" %>';
666
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
667
let lastHinanaScore = 0;
668
669
function sat(x, cap) {
670
return cap * (1 - Math.exp(-x / cap));
671
}
672
673
function calcSkillHinanaScore(timeSec, lines, lines4, tspinLines) {
674
const t = Math.max(timeSec, 90);
675
const lpm = (lines * 60) / t;
676
const tpm = ((lines4 / 4) * 60) / t;
677
const tspm = (tspinLines * 60) / t;
678
return Math.round(1000 * sat(lpm, 35) + 400 * sat(tpm, 6) + 500 * sat(tspm, 8));
679
}
680
681
function gameOver() {
682
isPaused = true;
683
document.body.classList.remove('game-active');
684
685
resultArea.classList.remove('d-none');
686
const hinanaScore = calcSkillHinanaScore(stats.playTime, stats.totalLines, stats.tetrisLines, stats.tspinLines);
687
lastHinanaScore = hinanaScore;
688
finalScoreElement.innerText = hinanaScore.toLocaleString();
689
690
// 책갈피 전환 영역 초기화
691
const claimArea = document.getElementById('bookmark-claim-area');
692
const claimResult = document.getElementById('claim-result');
693
claimArea.classList.add('d-none');
694
claimResult.classList.add('d-none');
695
696
// Guest는 서버에 제출하지 않음
697
if (!loggedInUsername) {
698
let warningMsg = document.getElementById('score-warning');
699
if (!warningMsg) {
700
warningMsg = document.createElement('div');
701
warningMsg.id = 'score-warning';
702
warningMsg.className = 'small text-danger fw-bold mt-2';
703
resultArea.appendChild(warningMsg);
704
}
705
warningMsg.innerText = "※ 로그인 후 플레이하면 랭킹에 등록됩니다.";
706
overlay.style.display = 'flex';
707
return;
708
}
709
710
fetch('/hinana/tetris/score', {
711
method: 'POST',
712
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
713
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
714
score: player.score,
715
time: stats.playTime,
716
lines: stats.totalLines,
717
lines4: stats.tetrisLines,
718
tspinLines: stats.tspinLines
719
})
720
})
721
.then(res => res.json())
722
.then(data => {
723
if (data.success === false) {
724
let warningMsg = document.getElementById('score-warning');
725
if (!warningMsg) {
726
warningMsg = document.createElement('div');
727
warningMsg.id = 'score-warning';
728
warningMsg.className = 'small text-danger fw-bold mt-2';
729
resultArea.appendChild(warningMsg);
730
}
731
warningMsg.innerText = "※ " + (data.message || "랭킹에 반영되지 않았습니다.");
732
} else {
733
const warningMsg = document.getElementById('score-warning');
734
if (warningMsg) warningMsg.remove();
735
736
// 책갈피 전환 가능 여부 확인
737
const claimable = Math.min(Math.floor(hinanaScore / 10000), 10);
738
if (canClaimTetris && claimable > 0) {
739
document.getElementById('claim-amount').innerText = claimable + '개';
740
claimArea.classList.remove('d-none');
741
}
742
}
743
overlay.style.display = 'flex';
744
})
745
.catch(err => {
746
console.error("Score submit error:", err);
747
overlay.style.display = 'flex';
748
});
749
}
750
751
function claimBookmarks() {
752
fetch('/hinana/tetris/claim-bookmarks', {
753
method: 'POST',
754
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
755
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
756
})
757
.then(res => res.json())
758
.then(data => {
759
const claimResult = document.getElementById('claim-result');
760
claimResult.classList.remove('d-none');
761
if (data.success) {
762
claimResult.className = 'small mt-2 text-warning fw-bold';
763
claimResult.innerText = '책갈피 ' + data.awarded + '개가 적립되었습니다! (총 ' + data.total + '개)';
764
canClaimTetris = false; // 오늘 전환 완료
765
} else {
766
claimResult.className = 'small mt-2 text-danger fw-bold';
767
claimResult.innerText = data.message || '전환에 실패했습니다.';
768
}
769
// 버튼 숨기기
770
document.querySelectorAll('#bookmark-claim-area button').forEach(btn => btn.style.display = 'none');
771
})
772
.catch(err => {
773
console.error("Claim error:", err);
774
});
775
}
776
777
function dismissClaim() {
778
document.getElementById('bookmark-claim-area').classList.add('d-none');
779
}
780
781
function playerHold() {
782
if (isPaused || !player.canHold) return;
783
784
if (player.holdType === null) {
785
player.holdType = player.currentType;
786
playerReset();
787
} else {
788
const temp = player.currentType;
789
player.currentType = player.holdType;
790
player.holdType = temp;
791
player.matrix = createPiece(player.currentType);
792
player.pos.y = 0;
793
player.pos.x = Math.floor((arena[0].length - player.matrix[0].length) / 2);
794
player.rotation = 0; // 홀드 시 회전 초기화
795
}
796
player.canHold = false;
797
lockTimer = 0;
798
lockResets = 0;
799
player.lastMoveRotate = false;
800
}
801
802
function playerHardDrop() {
803
if (isPaused) return;
804
while (!collide(arena, player)) { player.pos.y++; }
805
player.pos.y--;
806
merge(arena, player);
807
playerReset();
808
arenaSweep();
809
updateScore();
810
dropCounter = 0;
811
shake = 3;
812
player.lastMoveRotate = false;
813
}
814
815
function playerDrop() {
816
if (isPaused) return;
817
player.pos.y++;
818
if (collide(arena, player)) {
819
player.pos.y--;
820
return true;
821
}
822
player.lastMoveRotate = false;
823
return false;
824
}
825
826
function playerMove(dir) {
827
if (isPaused) return;
828
player.pos.x += dir;
829
if (collide(arena, player)) {
830
player.pos.x -= dir;
831
} else {
832
if (isGrounded() && lockResets < LOCK_RESET_LIMIT) { lockTimer = 0; lockResets++; }
833
player.lastMoveRotate = false;
834
}
835
}
836
837
// [★수정] SRS 기반 회전 로직 (Super Rotation System)
838
function playerRotate(dir) {
839
if (isPaused) return;
840
841
const originalPos = player.pos.x;
842
const originalY = player.pos.y;
843
const originalMat = player.matrix.map(row => [...row]); // 백업
844
const oldRotation = player.rotation;
845
846
// 1. 매트릭스 회전
847
rotate(player.matrix, dir);
848
849
// 2. 새 회전 상태 계산 (0->1->2->3 or 0->3->2->1)
850
let newRotation = (oldRotation + dir);
851
if (newRotation < 0) newRotation = 3;
852
if (newRotation > 3) newRotation = 0;
853
854
// 3. 킥 테이블 조회
855
const kickKey = oldRotation + '-' + newRotation;
856
let kicks;
857
858
if (player.currentType === 'I') {
859
kicks = I_WALL_KICKS[kickKey];
860
} else if (player.currentType === 'O') {
861
kicks = [[0,0]]; // O블록은 킥 없음
862
} else {
863
kicks = JLSTZ_WALL_KICKS[kickKey];
864
}
865
866
// 4. 테스트 (Wall Kick Test)
867
let rotated = false;
868
for (let i = 0; i < kicks.length; i++) {
869
const [ox, oy] = kicks[i];
870
player.pos.x += ox;
871
player.pos.y -= oy; // Y축은 위가 -방향이므로 뺌
872
873
if (!collide(arena, player)) {
874
rotated = true;
875
player.rotation = newRotation;
876
player.lastMoveRotate = true; // 스핀 판정용 플래그
877
if (isGrounded() && lockResets < LOCK_RESET_LIMIT) { lockTimer = 0; lockResets++; }
878
break;
879
} else {
880
// 실패 시 원복하고 다음 테스트
881
player.pos.x -= ox;
882
player.pos.y += oy;
883
}
884
}
885
886
// 5. 모든 킥 실패 시 회전 취소
887
if (!rotated) {
888
player.matrix = originalMat;
889
player.pos.x = originalPos;
890
player.pos.y = originalY;
891
}
892
}
893
894
function isGrounded() {
895
player.pos.y++;
896
let grounded = collide(arena, player);
897
player.pos.y--;
898
return grounded;
899
}
900
901
// [★수정] All-Spin 감지 (T-Spin 포함)
902
function checkSpin() {
903
if (!player.lastMoveRotate) return null; // 회전 안했으면 무효
904
if (player.currentType !== 'T') return null; // T-Spin만 인정
905
906
let corners = 0;
907
// 현재 블록의 중심 기준이 아니라, 3x3 박스 내의 네 귀퉁이 검사
908
// SRS에서 T블록 중심은 (1,1)
909
const checkPoints = [[0, 0], [2, 0], [0, 2], [2, 2]];
910
911
checkPoints.forEach(([kx, ky]) => {
912
const x = player.pos.x + kx;
913
const y = player.pos.y + ky;
914
if (arena[y] === undefined || arena[y][x] === undefined || arena[y][x] !== 0) {
915
corners++;
916
}
917
});
918
919
if (corners >= 3) {
920
return player.currentType; // 'T', 'S', 'Z' 등 반환
921
}
922
return null;
923
}
924
925
function collide(arena, player) {
926
const [m, o] = [player.matrix, player.pos];
927
for (let y = 0; y < m.length; ++y) {
928
for (let x = 0; x < m[y].length; ++x) {
929
if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) {
930
return true;
931
}
932
}
933
}
934
return false;
935
}
936
937
function arenaSweep() {
938
let rowCount = 0;
939
outer: for (let y = arena.length - 1; y > 0; --y) {
940
for (let x = 0; x < arena[y].length; ++x) {
941
if (arena[y][x] === 0) continue outer;
942
}
943
const row = arena.splice(y, 1)[0].fill(0);
944
arena.unshift(row);
945
++y;
946
rowCount++;
947
}
948
949
const spinType = checkSpin(); // 스핀 체크
950
951
if (rowCount > 0 || spinType) {
952
stats.totalLines += rowCount;
953
if (rowCount === 4) stats.tetrisLines += 4;
954
if (spinType && rowCount > 0) stats.tspinLines += rowCount;
955
956
let addedScore = 0;
957
let text = "";
958
let color = "#fff";
959
960
// [★수정] 점수 로직 (Spin 우선)
961
if (spinType) {
962
const name = spinType + "-SPIN";
963
color = "#D500F9";
964
if (rowCount === 0) { addedScore = 100; text = name; } // Mini or Twist (no clear)
965
else if (rowCount === 1) { addedScore = 800; text = name + " SINGLE!"; }
966
else if (rowCount === 2) { addedScore = 1200; text = name + " DOUBLE!"; }
967
else if (rowCount === 3) { addedScore = 1600; text = name + " TRIPLE!"; }
968
shake = 5;
969
} else {
970
if (rowCount === 1) { addedScore = 100; text = "+100"; }
971
else if (rowCount === 2) { addedScore = 300; text = "DOUBLE! +300"; color = "#00C7FF"; }
972
else if (rowCount === 3) { addedScore = 500; text = "TRIPLE! +500"; color = "#FF9100"; }
973
else if (rowCount === 4) { addedScore = 800; text = "TETRIS!!! +800"; color = "#D500F9"; shake = 10; }
974
}
975
976
if (addedScore > 0) {
977
player.score += addedScore * player.level;
978
if(text) {
979
effects.push({
980
x: arena[0].length / 2, y: arena.length / 2,
981
text: text, color: color, life: 1.0
982
});
983
}
984
}
985
986
const newLevel = Math.floor(stats.totalLines / 5) + 1;
987
if (newLevel > player.level) {
988
player.level = newLevel;
989
levelElement.innerText = "LV " + player.level;
990
levelElement.classList.add('level-up-anim');
991
setTimeout(() => levelElement.classList.remove('level-up-anim'), 500);
992
dropInterval = 1000 * Math.pow(0.8, player.level - 1);
993
effects.push({ x: arena[0].length / 2, y: arena.length / 2 - 2, text: "LEVEL UP!", color: "#FF3385", life: 1.5 });
994
}
995
}
996
}
997
998
let dropCounter = 0;
999
let dropInterval = 1000;
1000
let lastTime = 0;
1001
let isPaused = true;
1002
const ENABLE_HOLD_R_RESTART = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
1003
1004
let rHoldTimer = null;
1005
let rIsHolding = false;
1006
1007
const R_HOLD_MS = 650;
1008
let lockTimer = 0;
1009
const LOCK_DELAY = 500;
1010
1011
function update(time = 0) {
1012
if (isPaused) return;
1013
const deltaTime = time - lastTime;
1014
lastTime = time;
1015
1016
handleInput(deltaTime);
1017
1018
if (stats.startTime > 0) {
1019
stats.playTime = Math.floor((Date.now() - stats.startTime) / 1000);
1020
const min = String(Math.floor(stats.playTime / 60)).padStart(2, '0');
1021
const sec = String(stats.playTime % 60).padStart(2, '0');
1022
timerElement.innerText = `${min}:${sec}`;
1023
}
1024
1025
dropCounter += deltaTime;
1026
if (dropCounter > dropInterval) {
1027
player.pos.y++;
1028
if (collide(arena, player)) {
1029
player.pos.y--;
1030
} else {
1031
lockTimer = 0;
1032
player.lastMoveRotate = false;
1033
}
1034
dropCounter = 0;
1035
}
1036
1037
if (isGrounded()) {
1038
lockTimer += deltaTime;
1039
if (lockTimer > LOCK_DELAY) {
1040
merge(arena, player);
1041
playerReset();
1042
arenaSweep();
1043
updateScore();
1044
lockTimer = 0;
1045
}
1046
}
1047
1048
draw();
1049
requestAnimationFrame(update);
1050
}
1051
1052
function handleInput(dt) {
1053
[37, 39].forEach(code => {
1054
if (keyState[code]) {
1055
if (keyTimers[code] === 0) {
1056
playerMove(code === 37 ? -1 : 1);
1057
keyTimers[code] += dt;
1058
} else {
1059
keyTimers[code] += dt;
1060
if (keyTimers[code] > DAS_DELAY) {
1061
while (keyTimers[code] > DAS_DELAY + ARR_DELAY) {
1062
playerMove(code === 37 ? -1 : 1);
1063
keyTimers[code] -= ARR_DELAY;
1064
}
1065
}
1066
}
1067
} else {
1068
keyTimers[code] = 0;
1069
}
1070
});
1071
1072
if (keyState[40]) {
1073
if (keyTimers[40] === 0) {
1074
playerDrop();
1075
keyTimers[40] += dt;
1076
} else {
1077
keyTimers[40] += dt;
1078
if (keyTimers[40] > ARR_DELAY) {
1079
playerDrop();
1080
keyTimers[40] = 0;
1081
}
1082
}
1083
} else {
1084
keyTimers[40] = 0;
1085
}
1086
}
1087
1088
function startMove(code) { keyState[code] = true; }
1089
function stopMove(code) { keyState[code] = false; }
1090
function triggerInput(code) {
1091
const e = { keyCode: code, preventDefault: () => {} };
1092
document.dispatchEvent(new KeyboardEvent('keydown', e));
1093
}
1094
1095
function updateScore() {
1096
scoreElement.innerText = player.score;
1097
}
1098
1099
function startGame() {
1100
lastTime = 0; // ✅ 추가: 프레임 타이밍 초기화
1101
isPaused = true; // ✅ 추가: 혹시 돌고 있던 루프 잠깐 멈춤(중복 방지)
1102
document.body.classList.add('game-active');
1103
window.scrollTo(0, 0);
1104
1105
overlay.style.display = 'none';
1106
resultArea.classList.add('d-none');
1107
1108
bag = [];
1109
1110
arena.forEach(row => row.fill(0));
1111
player.score = 0;
1112
player.level = 1;
1113
player.holdType = null;
1114
player.nextType = null;
1115
player.lastMoveRotate = false;
1116
1117
dropInterval = 1000;
1118
lockTimer = 0;
1119
lockResets = 0;
1120
1121
stats = { startTime: Date.now(), playTime: 0, totalLines: 0, tetrisLines: 0, tspinLines: 0 };
1122
timerElement.innerText = "00:00";
1123
levelElement.innerText = "LV 1";
1124
1125
effects = [];
1126
updateScore();
1127
playerReset();
1128
isPaused = false;
1129
update();
1130
}
1131
1132
document.addEventListener('keydown', event => {
1133
if (isPaused) return;
1134
1135
if([32, 37, 38, 39, 40].includes(event.keyCode)) {
1136
event.preventDefault();
1137
}
1138
1139
if (event.keyCode === 37 || event.keyCode === 39 || event.keyCode === 40) {
1140
if (!keyState[event.keyCode]) {
1141
keyState[event.keyCode] = true;
1142
keyTimers[event.keyCode] = 0;
1143
}
1144
}
1145
else if (event.keyCode === 38) { playerRotate(1); }
1146
else if (event.keyCode === 90) { playerRotate(-1); } // Z key
1147
else if (event.keyCode === 32) { playerHardDrop(); }
1148
else if (event.keyCode === 16 || event.keyCode === 67) { playerHold(); }
1149
});
1150
1151
document.addEventListener('keyup', event => {
1152
if (event.keyCode === 37 || event.keyCode === 39 || event.keyCode === 40) {
1153
keyState[event.keyCode] = false;
1154
}
1155
});
1156
1157
document.addEventListener('keydown', (event) => {
1158
if (!ENABLE_HOLD_R_RESTART) return;
1159
1160
// R 키 (82)
1161
if (event.keyCode === 82) {
1162
event.preventDefault();
1163
1164
if (rIsHolding) return; // 키 반복 입력 방지
1165
rIsHolding = true;
1166
1167
// 일정 시간 유지되면 재시작
1168
rHoldTimer = setTimeout(() => {
1169
// 여전히 누르고 있다면 재시작
1170
if (rIsHolding) startGame();
1171
}, R_HOLD_MS);
1172
}
1173
});
1174
1175
document.addEventListener('keyup', (event) => {
1176
if (event.keyCode === 82) {
1177
rIsHolding = false;
1178
if (rHoldTimer) {
1179
clearTimeout(rHoldTimer);
1180
rHoldTimer = null;
1181
}
1182
}
1183
});
1184
</script>
1185
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
1186
</body>
1187
</html>
1188