Public Source Viewer

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

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

Redacted View
view/hinana/train.ejs
공개 가능
1 <%
2 const tType = (typeof trainType !== 'undefined') ? trainType : 'hinana';
3 let signColor, signName, signNameEn, trainTitle;
4 if (tType === 'nozomi') {
5 signColor = '#FFC639'; signName = '노조미'; signNameEn = 'NOZOMI'; trainTitle = '특급 노조미호';
6 } else if (tType === 'hikari') {
7 signColor = '#e60012'; signName = '히카리'; signNameEn = 'HIKARI'; trainTitle = '특급 히카리호';
8 } else {
9 signColor = '#FFC639'; signName = '히나나'; signNameEn = 'HINANA'; trainTitle = '특급 히나나호';
10 }
11 %>
12 <!DOCTYPE html>
13 <html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
14 <head>
15 <meta charset="utf-8" />
16 <meta name="color-scheme" content="light dark">
17 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
18 <link rel="manifest" href="/manifest.json">
19 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
20 <meta name="apple-mobile-web-app-title" content="비나래 라운지">
21 <meta property="og:image" content="/image/train_hinana.png" />
22 <meta property="og:description" content="특급 히나나호"/>
23 <meta property="og:url" content="hinana.moe/hinana/lounge/train"/>
24 <meta property="og:title" content="비나래 라운지"/>
25 <title>비나래 라운지 - <%- trainTitle %></title>
26
27 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
28 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
29 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
30 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
31 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
32 <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
33 <script src="/js/popup.js"></script>
34
35 <style>
36 :root {
37 --bg-color: #0a0e17;
38 --accent-color: #c5a059;
39 --text-color: #e7e5e4;
40 --korail-red: #e60012;
41 --korail-gray: #d9d9d9;
42 --hinana-yellow: #FFC639;
43 }
44
45 body, html {
46 margin: 0; padding: 0; width: 100%; height: 100%;
47 background-color: var(--bg-color); color: var(--text-color);
48 overflow: hidden;
49 font-family: 'Noto Sans KR', sans-serif;
50 user-select: none;
51 }
52
53 .scenery-layer {
54 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
55 background-repeat: repeat-x;
56 background-position: 0 center;
57 background-size: auto 100%;
58 z-index: 1;
59 transition: opacity 1.5s ease-in-out;
60 will-change: background-position;
61 }
62
63 .window-frame {
64 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
65 background: url('/image/train_window.png') no-repeat center center;
66 background-size: cover;
67 z-index: 3; pointer-events: none;
68 box-shadow: inset 0 0 100px rgba(0,0,0,0.8);
69 }
70
71 .rain-container {
72 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
73 z-index: 2; pointer-events: none; overflow: hidden;
74 }
75
76 .raindrop {
77 position: absolute;
78 width: 2px;
79 background: linear-gradient(to bottom,
80 rgba(255, 255, 255, 0) 0%,
81 rgba(255, 255, 255, 0.6) 50%,
82 rgba(255, 255, 255, 0) 100%);
83 animation: fall linear infinite;
84 opacity: 0.7;
85 }
86
87 @keyframes fall {
88 0% {
89 transform: translateY(-100px);
90 opacity: 0;
91 }
92 10% {
93 opacity: 0.7;
94 }
95 90% {
96 opacity: 0.7;
97 }
98 100% {
99 transform: translateY(100vh);
100 opacity: 0;
101 }
102 }
103
104 .water-drop {
105 position: absolute;
106 background: radial-gradient(circle,
107 rgba(255, 255, 255, 0.4) 0%,
108 rgba(255, 255, 255, 0.2) 40%,
109 rgba(255, 255, 255, 0) 100%);
110 border-radius: 50%;
111 animation: slide linear;
112 opacity: 0.6;
113 }
114
115 @keyframes slide {
116 0% {
117 transform: translateY(0);
118 opacity: 0;
119 }
120 10% {
121 opacity: 0.6;
122 }
123 90% {
124 opacity: 0.6;
125 }
126 100% {
127 transform: translateY(100vh);
128 opacity: 0;
129 }
130 }
131
132 .character-img {
133 position: absolute; bottom: 0;
134 height: 60vh; width: auto;
135 z-index: 5; display: none; cursor: pointer;
136 filter: drop-shadow(0 0 10px rgba(0,0,0,0.5));
137 transition: transform 0.2s; pointer-events: auto;
138 }
139 .character-img:hover {
140 transform: scale(1.02);
141 filter: drop-shadow(0 0 15px rgba(255,215,0,0.4));
142 }
143
144 #hinana { right: 15%; }
145 #madoka { left: 15%; }
146
147 .speech-bubble {
148 position: absolute; background: #fff; color: #333;
149 padding: 10px 20px; border-radius: 20px;
150 font-weight: bold; font-size: 0.9rem;
151 z-index: 6; opacity: 0; transition: opacity 0.3s;
152 pointer-events: none; box-shadow: 0 5px 15px rgba(0,0,0,0.3);
153 white-space: nowrap;
154 }
155
156 #hinana-bubble { bottom: 65vh; right: 18%; }
157 #hinana-bubble::after {
158 content: ''; position: absolute; bottom: -10px; left: 50%;
159 border-width: 10px 10px 0; border-style: solid;
160 border-color: #fff transparent; transform: translateX(-50%);
161 }
162
163 #madoka-bubble { bottom: 65vh; left: 18%; }
164 #madoka-bubble::after {
165 content: ''; position: absolute; bottom: -10px; left: 50%;
166 border-width: 10px 10px 0; border-style: solid;
167 border-color: #fff transparent; transform: translateX(-50%);
168 }
169
170 .speed-effect {
171 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
172 background: radial-gradient(circle, transparent 50%, rgba(0,0,0,0.6) 100%);
173 z-index: 3; pointer-events: none;
174 }
175
176 .ui-layer {
177 position: absolute; top: 0; left: 0; width: 100%; height: 100%;
178 z-index: 10;
179 display: flex; flex-direction: column; justify-content: center; align-items: center;
180 pointer-events: none;
181 }
182
183 #start-overlay, .ticket-btn, .ctrl-btn,
184 .train-logo-container, input[type=range], .ticket-link,
185 .edit-box input, .edit-box button, .form-check-input, .edit-close-btn {
186 pointer-events: auto;
187 }
188
189 .sound-controls {
190 position: absolute; top: 20px; right: 200px;
191 display: none;
192 flex-direction: column; gap: 10px;
193 z-index: 20; pointer-events: auto;
194 background: rgba(0, 0, 0, 0.3); padding: 10px;
195 border-radius: 10px; backdrop-filter: blur(2px);
196 }
197
198 .sound-row { display: flex; align-items: center; gap: 10px; }
199 .sound-icon { color: #fff; font-size: 1.2rem; cursor: pointer; width: 20px; text-align: center; }
200
201 input[type=range] {
202 -webkit-appearance: none; width: 100px; height: 4px;
203 background: rgba(255,255,255,0.3); border-radius: 2px; outline: none; cursor: pointer;
204 }
205 input[type=range]::-webkit-slider-thumb {
206 -webkit-appearance: none; width: 14px; height: 14px;
207 background: var(--accent-color); border-radius: 50%; border: 2px solid #000;
208 transition: transform 0.1s;
209 }
210
211 #start-overlay {
212 background: rgba(0, 0, 0, 0.85); width: 100%; height: 100%;
213 display: flex; flex-direction: column; justify-content: center; align-items: center;
214 transition: opacity 1s ease; backdrop-filter: blur(5px);
215 }
216
217 .ticket-btn {
218 background: transparent; border: 1px solid var(--accent-color);
219 color: var(--accent-color); padding: 15px 40px;
220 font-size: 1.2rem; letter-spacing: 2px;
221 cursor: pointer; transition: all 0.3s; text-transform: uppercase;
222 margin-bottom: 20px;
223 }
224 .ticket-btn:hover {
225 background: var(--accent-color); color: #000;
226 box-shadow: 0 0 20px rgba(197, 160, 89, 0.5);
227 }
228
229 .ticket-link {
230 color: var(--hinana-yellow); font-weight: 800; cursor: pointer;
231 text-decoration: underline; text-underline-offset: 3px;
232 transition: color 0.3s, text-shadow 0.3s;
233 }
234 .ticket-link:hover { color: #fff; text-shadow: 0 0 8px var(--hinana-yellow); }
235
236 .train-sign {
237 display: flex; width: 340px; height: 90px;
238 background: #fff; border: 4px solid var(--korail-gray);
239 border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
240 overflow: hidden; margin-top: 25px;
241 font-family: 'Malgun Gothic', 'Dotum', sans-serif;
242 }
243 .sign-left {
244 flex: 1; background-color: var(--hinana-yellow); color: #fff;
245 display: flex; flex-direction: column; justify-content: center; align-items: center;
246 border-right: 1px solid #ccc;
247 }
248
249 .sign-left h1 {
250 margin: 0; font-size: 1.4rem; font-weight: 800; line-height: 1.2;
251 white-space: nowrap; /* 히나나 줄바꿈 방지 */
252 }
253 .sign-left p { margin: 0; font-size: 0.75rem; font-weight: 400; opacity: 0.9; text-transform: uppercase; }
254
255 .sign-right {
256 flex: 2.2; background-color: #fff; color: #111;
257 display: flex; align-items: center; justify-content: space-evenly;
258 padding: 0 5px;
259 }
260
261 .location {
262 text-align: center;
263 flex: 1;
264 display: flex; flex-direction: column; justify-content: center; align-items: center;
265 overflow: hidden;
266 }
267
268 .location h2 {
269 margin: 0; font-size: 1.3rem; font-weight: 700; color: #000; letter-spacing: -1px;
270 white-space: normal !important;
271 word-break: break-all; /* 한글 짤림 방지 */
272 line-height: 1.1;
273 width: 100%;
274 transition: font-size 0.2s;
275 }
276
277 .location h2.sign-fs-md { font-size: 1.1rem; }
278 .location h2.sign-fs-sm { font-size: 0.95rem; }
279
280 .location p {
281 margin: 0; font-size: 0.7rem; color: #555; font-weight: 500;
282 white-space: normal !important;
283 word-break: break-all;
284 line-height: 1.0;
285 width: 100%;
286 }
287
288 .arrow-box { font-size: 1.8rem; color: #333; font-weight: lighter; padding-bottom: 5px; flex-shrink: 0; margin: 0 5px; }
289
290 .ctrl-btn {
291 position: absolute; top: 20px;
292 color: rgba(255,255,255,0.5); font-size: 1.8rem;
293 cursor: pointer; z-index: 20; transition: color 0.3s;
294 display: none; background: none; border: none; padding: 0;
295 }
296 .ctrl-btn:hover { color: #fff; }
297 #exit-btn { right: 20px; font-size: 2rem; }
298 #pause-btn { right: 80px; }
299 #fullscreen-btn { right: 140px; }
300
301 .train-logo-container {
302 position: absolute; top: 25px; left: 30px; z-index: 30;
303 opacity: 0.8; transition: opacity 0.3s; display: block;
304 }
305 .train-logo-container:hover { opacity: 1; }
306 .train-logo-img { height: 30px; width: auto; filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.8)); }
307
308 /* ========================================================= */
309 /* [NEW] 목적지 변경 모달 CSS */
310 /* ========================================================= */
311 .edit-box {
312 background: #fff; color: #333;
313 width: 350px; padding: 25px;
314 border-radius: 12px;
315 box-shadow: 0 10px 30px rgba(0,0,0,0.5);
316 display: flex; flex-direction: column; gap: 15px;
317 font-family: 'Noto Sans KR', sans-serif;
318 text-align: center;
319 position: relative; /* 닫기 버튼 배치를 위해 추가 */
320 }
321 .edit-box h3 { font-size: 1.2rem; font-weight: 800; color: var(--bg-color); margin-bottom: 5px; }
322 .edit-box input {
323 padding: 10px; border: 1px solid #ccc; border-radius: 6px;
324 font-size: 0.9rem; width: 100%;
325 }
326 .edit-box button:not(.edit-tab) {
327 background: var(--accent-color); color: #000;
328 border: none; padding: 12px; border-radius: 6px;
329 font-weight: bold; cursor: pointer; transition: opacity 0.2s;
330 }
331 .edit-box button:not(.edit-tab):hover { opacity: 0.9; }
332
333 .form-check { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 0.85rem; color: #555; }
334 .form-check-input { cursor: pointer; }
335
336 /* [NEW] 변경 모달 탭 스타일 */
337 .edit-tabs {
338 display: flex; gap: 0; border-bottom: 2px solid #eee; margin-bottom: 15px;
339 }
340 .edit-tab {
341 flex: 1; padding: 10px; border: none; background: none;
342 font-size: 0.9rem; font-weight: 600; color: #999; cursor: pointer;
343 border-bottom: 2px solid transparent; margin-bottom: -2px;
344 transition: color 0.2s, border-color 0.2s;
345 }
346 .edit-tab.active { color: var(--accent-color); border-bottom-color: var(--accent-color); }
347 .edit-tab:hover:not(.active) { color: #666; }
348
349 #edit-dest-area, #edit-depart-area { display: flex; flex-direction: column; gap: 10px; }
350
351 /* [NEW] 변경 모달 닫기 버튼 스타일 */
352 .edit-close-btn {
353 position: absolute; top: 15px; right: 15px;
354 font-size: 1.2rem; color: #999; cursor: pointer; transition: color 0.2s;
355 }
356 .edit-close-btn:hover { color: #333; }
357
358 /* ========================================================= */
359 /* 승차권 모달 및 액션 버튼 디자인 */
360 /* ========================================================= */
361 .ticket-modal-overlay {
362 position: fixed; top: 0; left: 0; width: 100%; height: 100%;
363 background: rgba(0, 0, 0, 0.7); z-index: 9999;
364 display: none; justify-content: center; align-items: center;
365 backdrop-filter: blur(5px);
366 opacity: 0; transition: opacity 0.3s ease;
367 pointer-events: auto;
368 }
369
370 .ticket-box {
371 display: flex; width: 650px; max-width: 90%;
372 background: #fdfbf7; border-radius: 12px;
373 box-shadow: 0 20px 50px rgba(0,0,0,0.6);
374 color: #333; position: relative;
375 font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif;
376 transform: translateY(20px); transition: transform 0.3s ease;
377 }
378 .ticket-modal-overlay.show { opacity: 1; }
379 .ticket-modal-overlay.show .ticket-box { transform: translateY(0); }
380
381 .ticket-left {
382 flex: 2.5; padding: 30px 40px;
383 border-right: 2px dashed #ccc;
384 }
385
386 .ticket-right {
387 flex: 1; padding: 30px 20px;
388 display: flex; flex-direction: column; align-items: center; justify-content: center;
389 background: #faf8f0; border-radius: 0 12px 12px 0;
390 position: relative;
391 }
392
393 .t-title, .t-no, .t-label, .t-right-text, .t-right-sub {
394 white-space: nowrap !important;
395 word-break: keep-all !important;
396 }
397
398 .t-header {
399 display: flex; justify-content: space-between; align-items: flex-end;
400 margin-bottom: 20px; border-bottom: 3px solid var(--hinana-yellow);
401 padding-bottom: 8px;
402 }
403 .t-title { font-size: 1.1rem; color: var(--accent-color); font-weight: 800; letter-spacing: 1px; }
404 .t-no { font-size: 0.75rem; color: #888; font-weight: normal; letter-spacing: 0; }
405
406 .t-route {
407 display: flex; justify-content: space-between; align-items: center;
408 margin-bottom: 25px; width: 100%;
409 }
410
411 .t-station {
412 text-align: center; width: 44%; overflow: hidden;
413 }
414
415 .t-station h2 {
416 margin: 0; font-size: 1.7rem; font-weight: 900; color: #111; letter-spacing: -1px;
417 white-space: nowrap;
418 word-break: keep-all; line-height: 1.1;
419 width: 100%; transition: font-size 0.2s;
420 }
421
422 .t-station h2.fs-md {
423 font-size: 1.35rem;
424 white-space: nowrap;
425 }
426
427 .t-station h2.fs-sm {
428 font-size: 1.1rem;
429 white-space: normal;
430 }
431
432 .t-station p {
433 margin: 0; font-size: 0.8rem; color: #666; text-transform: uppercase; font-weight: bold;
434 white-space: normal !important; word-break: break-all; line-height: 1.1;
435 width: 100%; transition: font-size 0.2s;
436 }
437
438 .t-station p.fs-sub-sm { font-size: 0.65rem; letter-spacing: -0.5px; }
439
440 .t-arrow {
441 font-size: 1.5rem; color: var(--accent-color); width: 12%; text-align: center; flex-shrink: 0;
442 }
443
444 .t-details { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 15px; }
445 .t-item { flex: 0 0 45%; width: 45%; min-width: 0; }
446 .t-label { font-size: 0.75rem; color: #888; margin-bottom: 4px; font-weight: bold; }
447
448 .t-value {
449 font-size: 1.1rem; font-weight: 800; color: #222;
450 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
451 }
452
453 .t-barcode {
454 width: 100%; height: 50px; margin-bottom: 15px; opacity: 0.8;
455 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='50'%3E%3Crect x='0' width='3' height='50' fill='%23333'/%3E%3Crect x='5' width='1' height='50' fill='%23333'/%3E%3Crect x='8' width='4' height='50' fill='%23333'/%3E%3Crect x='14' width='2' height='50' fill='%23333'/%3E%3Crect x='18' width='1' height='50' fill='%23333'/%3E%3Crect x='21' width='2' height='50' fill='%23333'/%3E%3C/svg%3E");
456 background-repeat: repeat-x;
457 }
458 .t-right-text { font-size: 0.9rem; color: #555; text-align: center; font-weight: 800; letter-spacing: 1px;}
459 .t-text-highlight { color: var(--hinana-yellow); }
460 .t-right-sub { font-size: 0.7rem; color: var(--accent-color); margin-top: 5px;}
461
462 .t-right-logo {
463 display: block;
464 width: 90%; max-width: 160px; height: auto;
465 margin-top: 25px; opacity: 1.0;
466 filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
467 }
468
469 .t-stamp {
470 display: block;
471 width: 70%; max-width: 100px; height: auto;
472 margin-top: 12px;
473 opacity: 0.75;
474 transform: rotate(-12deg);
475 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.2));
476 mix-blend-mode: multiply;
477 }
478
479 .ticket-actions {
480 position: absolute; top: -40px; right: 0;
481 display: flex; gap: 15px; z-index: 10;
482 }
483 .ticket-action-btn {
484 color: #fff; font-size: 1.8rem; cursor: pointer;
485 transition: color 0.2s, transform 0.2s;
486 }
487 .ticket-action-btn:hover {
488 color: var(--hinana-yellow); transform: scale(1.1);
489 }
490
491 /* ========================================================= */
492 /* 하차 확인 모달 CSS */
493 /* ========================================================= */
494 .exit-confirm-modal {
495 position: fixed; top: 0; left: 0; width: 100%; height: 100%;
496 background: rgba(0, 0, 0, 0.85); z-index: 10000;
497 display: none; justify-content: center; align-items: center;
498 backdrop-filter: blur(8px); opacity: 0; transition: opacity 0.3s ease;
499 pointer-events: auto;
500 }
501 .exit-confirm-modal.show { opacity: 1; }
502
503 .exit-confirm-box {
504 background: #1a2238; color: #e7e5e4;
505 width: 420px; max-width: 90%;
506 padding: 40px; border-radius: 8px;
507 border-top: 5px solid var(--accent-color);
508 box-shadow: 0 20px 60px rgba(0,0,0,0.7);
509 font-family: 'Noto Sans KR', sans-serif;
510 text-align: center; position: relative;
511 transform: translateY(20px); transition: transform 0.3s ease;
512 }
513 .exit-confirm-modal.show .exit-confirm-box { transform: translateY(0); }
514
515 .exit-confirm-title {
516 font-size: 1.5rem; font-weight: 800; color: #fff;
517 margin-bottom: 30px; letter-spacing: -0.5px;
518 }
519
520 .exit-confirm-stats {
521 background: rgba(255,255,255,0.05);
522 border: 1px solid rgba(197,160,89,0.3);
523 border-radius: 6px; padding: 25px; margin-bottom: 30px;
524 }
525
526 .exit-stat-item {
527 display: flex; justify-content: space-between; align-items: center;
528 margin-bottom: 15px; padding-bottom: 15px;
529 border-bottom: 1px solid rgba(255,255,255,0.1);
530 }
531 .exit-stat-item:last-child {
532 margin-bottom: 0; padding-bottom: 0; border-bottom: none;
533 }
534
535 .exit-stat-label {
536 font-size: 0.9rem; color: #a8a29e; font-weight: 500;
537 }
538 .exit-stat-value {
539 font-size: 1.3rem; font-weight: 800; color: var(--accent-color);
540 }
541
542 .exit-confirm-buttons {
543 display: flex; gap: 15px; justify-content: center;
544 }
545
546 .exit-confirm-btn {
547 flex: 1; padding: 15px 25px; border: none; border-radius: 6px;
548 font-size: 1rem; font-weight: 700; cursor: pointer;
549 transition: all 0.2s; font-family: 'Noto Sans KR', sans-serif;
550 }
551
552 .exit-confirm-btn.cancel {
553 background: transparent;
554 border: 2px solid var(--accent-color);
555 color: var(--accent-color);
556 }
557 .exit-confirm-btn.cancel:hover {
558 background: var(--accent-color); color: #000;
559 }
560
561 .exit-confirm-btn.confirm {
562 background: var(--accent-color); color: #000;
563 }
564 .exit-confirm-btn.confirm:hover {
565 background: #d4b47a; transform: translateY(-2px);
566 }
567
568 @media screen and (orientation: portrait) and (max-width: 991px) {
569 body {
570 width: 100vh; height: 100vw;
571 transform: rotate(90deg); transform-origin: top left;
572 position: absolute; top: 0; left: 100%;
573 overflow: hidden; margin: 0;
574 }
575 .ui-layer { width: 100vh; height: 100vw; }
576 .character-img { height: 75vw !important; }
577
578 #hinana-bubble { bottom: 40vw !important; right: 40% !important; font-size: 0.8rem; }
579 #hinana-bubble::after { top: 50%; right: -10px; left: auto; bottom: auto; border-width: 10px 0 10px 10px; border-color: transparent transparent transparent #fff; transform: translateY(-50%); }
580
581 #madoka-bubble { bottom: 40vw !important; left: 40% !important; font-size: 0.8rem; }
582 #madoka-bubble::after { top: 50%; left: -10px; right: auto; bottom: auto; border-width: 10px 10px 10px 0; border-color: transparent #fff transparent transparent; transform: translateY(-50%); }
583
584 .sound-controls { right: 180px; }
585
586 .ticket-box { width: 85vh; }
587 .ticket-left { padding: 20px; }
588 .t-station h2 { font-size: 1.5rem; }
589 }
590 </style>
591 </head>
592 <body>
593
594 <div class="scenery-layer" id="scenery"></div>
595 <div class="window-frame"></div>
596 <div class="rain-container" id="rain-container"></div>
597
598 <img src="/image/hinana_train.png" class="character-img" id="hinana" onclick="talkHinana()" alt="이치카와 히나나">
599 <div class="speech-bubble" id="hinana-bubble">아하~ 프로듀서?</div>
600
601 <img src="/image/madoka_train.png" class="character-img" id="madoka" onclick="talkMadoka()" alt="히구치 마도카">
602 <div class="speech-bubble" id="madoka-bubble">...하아.</div>
603
604 <div class="speed-effect"></div>
605
606 <div class="ui-layer">
607 <a href="/hinana/lounge" class="train-logo-container" id="train-logo" title="라운지로 돌아가기">
608 <img src="/image/lounge1.png" alt="비나래 라운지" class="train-logo-img">
609 </a>
610
611 <div id="start-overlay">
612 <h2 class="mb-4 fw-light" style="color: #fff; letter-spacing: 5px; opacity: 0.8;">WELCOME ABOARD</h2>
613 <button class="ticket-btn" onclick="departTrain()">BOARD THE TRAIN</button>
614 <p class="text-secondary small mb-0">이어폰 착용을 권장합니다.</p>
615 <p class="text-secondary small mb-0">모바일은 전체화면을 권장합니다.</p>
616
617 <p class="text-secondary small mt-3 mb-0" style="font-size: 0.85rem; color: #ddd !important;">
618 또한, 가지고 계신 <span class="ticket-link" onclick="openTicket()">[승차권]</span>을 제대로 확인하신 후 승차하시길 바랍니다.
619 </p>
620
621 <p class="text-secondary small mt-2 mb-0" style="font-size: 0.8rem; color: #ddd !important;">
622 혹시, <span class="ticket-link" onclick="openEditModal()">[승차권 변경]</span>이 필요하신가요?
623 </p>
624
625 <div class="train-sign mt-4">
626 <div class="sign-left" style="background-color: <%- signColor %>;"> <h1><%- signName %></h1> <p><%- signNameEn %></p> </div>
627 <div class="sign-right">
628 <div class="location"> <h2 id="sign-depart-kr">라운지</h2> <p id="sign-depart-en">Lounge</p> </div>
629 <div class="arrow-box"><i class="bi bi-arrow-right"></i></div>
630 <div class="location"> <h2 id="sign-dest-kr">아카이브</h2> <p id="sign-dest-en">Archive</p> </div>
631 </div>
632 </div>
633 </div>
634
635 <div class="sound-controls" id="sound-controls">
636 <div class="sound-row" title="기차 주행음">
637 <i class="bi bi-train-front-fill sound-icon" id="train-vol-icon" onclick="toggleTrainMute()"></i>
638 <input type="range" id="train-vol-slider" min="0" max="1" step="0.01" value="1" oninput="adjustTrainVolume(this.value)">
639 </div>
640 <div class="sound-row" title="빗소리">
641 <i class="bi bi-cloud-rain-fill sound-icon" id="rain-vol-icon" onclick="toggleRainMute()"></i>
642 <input type="range" id="rain-vol-slider" min="0" max="1" step="0.01" value="1" oninput="adjustRainVolume(this.value)">
643 </div>
644 </div>
645
646 <button class="ctrl-btn" id="fullscreen-btn" onclick="toggleFullscreen()" title="전체화면"><i class="bi bi-arrows-fullscreen"></i></button>
647 <button class="ctrl-btn" id="pause-btn" onclick="togglePause()" title="일시정지"><i class="bi bi-pause-fill"></i></button>
648 <button class="ctrl-btn" id="exit-btn" onclick="showExitConfirm()" title="나가기"><i class="bi bi-x-lg"></i></button>
649 </div>
650
651 <div class="ticket-modal-overlay" id="edit-modal" onclick="closeEditModal(event)">
652 <div class="edit-box" onclick="event.stopPropagation()">
653 <i class="bi bi-x-lg edit-close-btn" onclick="closeEditModal()"></i>
654 <h3 id="edit-modal-title">승차권 변경</h3>
655
656 <!-- 탭 전환 -->
657 <div class="edit-tabs">
658 <button class="edit-tab active" id="tab-dest" onclick="switchEditTab('dest')">목적지</button>
659 <button class="edit-tab" id="tab-depart" onclick="switchEditTab('depart')">출발지</button>
660 </div>
661
662 <!-- 목적지 변경 영역 -->
663 <div id="edit-dest-area">
664 <input id="input-dest-kr" maxlength="10" placeholder="한글 행선지 (예: 트리니티)">
665 <div class="form-check form-switch">
666 <input class="form-check-input" type="checkbox" id="auto-eng-switch" checked>
667 <label class="form-check-label" for="auto-eng-switch">영문 자동 변환 (베타)</label>
668 </div>
669 <input id="input-dest-en" maxlength="25" placeholder="English Destination">
670 <button onclick="applyChange()">변경하기</button>
671 <button onclick="applyDestAndSwitchDepart()" style="background: #eee; color: #666; font-size: 0.85rem; padding: 10px;">출발지도 변경하기</button>
672 </div>
673
674 <!-- 출발지 변경 영역 -->
675 <div id="edit-depart-area" style="display: none;">
676 <p id="departure-info-text" style="font-size: 0.9rem; color: #666; margin: 0;"></p>
677 <div id="departure-confirm-buttons" style="display: none; gap: 10px;"></div>
678 <div id="departure-edit-fields" style="display: none;">
679 <input id="input-depart-kr" maxlength="10" placeholder="한글 출발지 (예: 미타카)">
680 <div class="form-check form-switch" style="margin: 10px 0;">
681 <input class="form-check-input" type="checkbox" id="auto-eng-depart-switch" checked>
682 <label class="form-check-label" for="auto-eng-depart-switch">영문 자동 변환 (베타)</label>
683 </div>
684 <input id="input-depart-en" maxlength="25" placeholder="English Departure">
685 <button onclick="applyDepartureChange()">변경하기</button>
686 </div>
687 </div>
688 </div>
689 </div>
690
691 <div class="ticket-modal-overlay" id="ticket-modal" onclick="closeTicket(event)">
692 <div class="ticket-box" id="capture-target" onclick="event.stopPropagation()">
693 <div class="ticket-actions" id="ticket-actions">
694 <i class="bi bi-clipboard ticket-action-btn me-2" onclick="copyTicketToClipboard()" title="클립보드에 복사"></i>
695 <i class="bi bi-download ticket-action-btn me-2" onclick="downloadTicket()" title="승차권 다운로드"></i>
696 <i class="bi bi-x-lg ticket-action-btn" onclick="closeTicket()" title="닫기"></i>
697 </div>
698
699 <div class="ticket-left">
700 <div class="t-header">
701 <span class="t-title">승차권 BOARDING PASS</span>
702 <span class="t-no">NO.283-0317</span>
703 </div>
704
705 <div class="t-route">
706 <div class="t-station">
707 <h2 id="ticket-depart-kr">비나래 라운지</h2>
708 <p id="ticket-depart-en">Binarae Lounge</p>
709 </div>
710 <div class="t-arrow"><i class="bi bi-caret-right-fill"></i></div>
711 <div class="t-station">
712 <h2 id="ticket-dest-kr">아카이브</h2>
713 <p id="ticket-dest-en">Archive</p>
714 </div>
715 </div>
716
717 <div class="t-details">
718 <div class="t-item">
719 <div class="t-label">탑승 일시 DATE & TIME</div>
720 <div class="t-value" id="ticket-time">00월 00일 00:00</div>
721 </div>
722 <div class="t-item">
723 <div class="t-label">탑승객 PASSENGER</div>
724 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
725 </div>
726 <div class="t-item">
727 <div class="t-label">열차 TRAIN</div>
728 <div class="t-value"><%- trainTitle %></div>
729 </div>
730 <div class="t-item">
731 <div class="t-label">좌석 SEAT</div>
732 <div class="t-value">H - 01석</div>
733 </div>
734 </div>
735 </div>
736
737 <div class="ticket-right">
738 <div class="t-barcode"></div>
739 <div class="t-right-text t-text-highlight"><%- signNameEn %></div>
740 <div class="t-right-text">Limited Express</div>
741 <div class="t-right-sub">특실 - FIRST CLASS</div>
742 <img src="/image/train.png" alt="비나래아카이브 철도국" class="t-right-logo">
743 <% if (tType === 'nozomi' || tType === 'hikari') { %>
744 <img src="/image/high.png" alt="HIGH" class="t-stamp">
745 <% } %>
746 </div>
747 </div>
748 </div>
749
750 <!-- 하차 확인 모달 -->
751 <div class="exit-confirm-modal" id="exit-confirm-modal" onclick="closeExitConfirm(event)">
752 <div class="exit-confirm-box" onclick="event.stopPropagation()">
753 <div class="exit-confirm-title">🚉 정말 하차 하시겠습니까?</div>
754
755 <div style="font-size: 0.9rem; color: #a8a29e; margin-bottom: 20px;">
756 <%- trainTitle %> : <span id="exit-route" style="color: var(--accent-color);">비나래 라운지 → 아카이브</span>
757 </div>
758
759 <div class="exit-confirm-stats">
760 <div class="exit-stat-item">
761 <span class="exit-stat-label">지나간 도시 수</span>
762 <span class="exit-stat-value" id="exit-cities-count">0개</span>
763 </div>
764 <div class="exit-stat-item">
765 <span class="exit-stat-label">함께 달려온 거리</span>
766 <span class="exit-stat-value" id="exit-distance">0 km</span>
767 </div>
768 </div>
769
770 <div class="exit-confirm-buttons">
771 <button class="exit-confirm-btn cancel" onclick="closeExitConfirm()">계속 탑승</button>
772 <button class="exit-confirm-btn confirm" onclick="confirmExit()">하차하기</button>
773 </div>
774 </div>
775 </div>
776
777 <script>
778 class SeamlessLoop {
779 constructor(src, overlapTime = 3) {
780 this.src = src; this.overlapTime = overlapTime;
781 this.volume = 1.0; this.isPlaying = false;
782 this.player1 = new Audio(src); this.player2 = new Audio(src);
783 this.activePlayer = this.player1; this.nextPlayer = this.player2;
784 this.player1.preload = 'auto'; this.player2.preload = 'auto';
785 this.player1.loop = false; this.player2.loop = false;
786 this.checkInterval = null;
787 }
788 play() {
789 if (this.isPlaying) return; this.isPlaying = true;
790 this.activePlayer.volume = this.volume;
791 this.activePlayer.play().catch(()=>{});
792 this.checkInterval = setInterval(() => this.checkLoop(), 100);
793 }
794 checkLoop() {
795 if (!this.activePlayer || this.activePlayer.paused) return;
796 const timeLeft = this.activePlayer.duration - this.activePlayer.currentTime;
797 if (timeLeft <= this.overlapTime && this.nextPlayer.paused) {
798 this.nextPlayer.currentTime = 0; this.nextPlayer.volume = this.volume;
799 this.nextPlayer.play();
800 const temp = this.activePlayer; this.activePlayer = this.nextPlayer; this.nextPlayer = temp;
801 }
802 }
803 pause() {
804 this.isPlaying = false; clearInterval(this.checkInterval);
805 this.player1.pause(); this.player2.pause();
806 }
807 setVolume(vol) {
808 this.volume = vol;
809 this.player1.volume = vol;
810 this.player2.volume = vol;
811 }
812 }
813
814 const scenery = document.getElementById('scenery');
815 const overlay = document.getElementById('start-overlay');
816 const soundControls = document.getElementById('sound-controls');
817 const exitBtn = document.getElementById('exit-btn');
818 const pauseBtn = document.getElementById('pause-btn');
819 const fullscreenBtn = document.getElementById('fullscreen-btn');
820
821 const trainVolSlider = document.getElementById('train-vol-slider');
822 const trainVolIcon = document.getElementById('train-vol-icon');
823 const rainVolSlider = document.getElementById('rain-vol-slider');
824 const rainVolIcon = document.getElementById('rain-vol-icon');
825
826 const hinanaImg = document.getElementById('hinana');
827 const hinanaBubble = document.getElementById('hinana-bubble');
828 const madokaImg = document.getElementById('madoka');
829 const madokaBubble = document.getElementById('madoka-bubble');
830
831 const signDestKr = document.getElementById('sign-dest-kr');
832 const signDestEn = document.getElementById('sign-dest-en');
833 const ticketDestKr = document.getElementById('ticket-dest-kr');
834 const ticketDestEn = document.getElementById('ticket-dest-en');
835
836 let departKr = '비나래 라운지';
837 let departEn = 'Binarae Lounge';
838 let departSignKr = '라운지';
839 let departSignEn = 'Lounge';
840
841 let userBookmarks = Number('<%= typeof bookmarks !== "undefined" ? bookmarks : 0 %>');
842 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
843
844 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
845 let destKr, destEn;
846 if (trainTypeFromServer === 'nozomi' || trainTypeFromServer === 'hikari') {
847 destKr = '키보토스';
848 destEn = 'Kivotos';
849 } else {
850 const randDest = Math.random();
851 destKr = '아카이브';
852 destEn = 'Archive';
853 if (randDest < 0.05) {
854 destKr = '키보토스';
855 destEn = 'Kivotos';
856 } else if (randDest < 0.12) {
857 destKr = '은하도서관';
858 destEn = 'Galaxy Library';
859 } else if (randDest < 0.21) {
860 destKr = '283 프로덕션';
861 destEn = '283 Production';
862 }
863 }
864
865 if(signDestKr) signDestKr.innerText = destKr;
866 if(signDestEn) signDestEn.innerText = destEn;
867 if(ticketDestKr) ticketDestKr.innerText = destKr;
868 if(ticketDestEn) ticketDestEn.innerText = destEn;
869
870 // [수정] 폰트 크기 자동 조절 함수 (행선판 포함)
871 function updateTicketFontSizes(krText, enText) {
872 const krEl = document.getElementById('ticket-dest-kr');
873 const enEl = document.getElementById('ticket-dest-en');
874 const signKrEl = document.getElementById('sign-dest-kr');
875
876 // 1. 티켓 폰트 조절
877 krEl.classList.remove('fs-md', 'fs-sm');
878 if (krText.length > 9) {
879 krEl.classList.add('fs-sm');
880 } else if (krText.length > 5) {
881 krEl.classList.add('fs-md');
882 }
883
884 enEl.classList.remove('fs-sub-sm');
885 if (enText.length > 10) {
886 enEl.classList.add('fs-sub-sm');
887 }
888
889 // 2. 행선판(Sign) 폰트 조절
890 signKrEl.classList.remove('sign-fs-md', 'sign-fs-sm');
891 if (krText.length > 6) {
892 signKrEl.classList.add('sign-fs-sm');
893 } else if (krText.length > 4) {
894 signKrEl.classList.add('sign-fs-md');
895 }
896 }
897
898 function updateDepartureFontSizes(krText, enText) {
899 const krEl = document.getElementById('ticket-depart-kr');
900 const enEl = document.getElementById('ticket-depart-en');
901 const signKrEl = document.getElementById('sign-depart-kr');
902
903 // 1. 티켓 폰트 조절
904 krEl.classList.remove('fs-md', 'fs-sm');
905 if (krText.length > 9) {
906 krEl.classList.add('fs-sm');
907 } else if (krText.length > 5) {
908 krEl.classList.add('fs-md');
909 }
910
911 enEl.classList.remove('fs-sub-sm');
912 if (enText.length > 10) {
913 enEl.classList.add('fs-sub-sm');
914 }
915
916 // 2. 행선판(Sign) 폰트 조절
917 signKrEl.classList.remove('sign-fs-md', 'sign-fs-sm');
918 if (krText.length > 6) {
919 signKrEl.classList.add('sign-fs-sm');
920 } else if (krText.length > 4) {
921 signKrEl.classList.add('sign-fs-md');
922 }
923 }
924
925 // 초기 로딩 시 적용 (목적지만 — 출발지는 기본 HTML이 이미 올바른 크기)
926 updateTicketFontSizes(destKr, destEn);
927
928 const editModal = document.getElementById('edit-modal');
929 const inputKr = document.getElementById('input-dest-kr');
930 const inputEn = document.getElementById('input-dest-en');
931 const autoSwitch = document.getElementById('auto-eng-switch');
932 const inputDepartKr = document.getElementById('input-depart-kr');
933 const inputDepartEn = document.getElementById('input-depart-en');
934 const autoDepartSwitch = document.getElementById('auto-eng-depart-switch');
935
936 let currentEditTab = 'dest';
937 let departureUnlocked = false; // 출발지 입력 필드 표시 여부 (책갈피 차감은 실제 변경 시)
938
939 function romanize(text) {
940 const chosung = ["g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s", "ss", "", "j", "jj", "ch", "k", "t", "p", "h"];
941 const jungsung = ["a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa", "wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i"];
942 const jongsung = ["", "k", "k", "ks", "n", "nj", "nh", "d", "l", "lg", "lm", "lb", "ls", "lt", "lp", "lh", "m", "b", "bs", "s", "ss", "ng", "j", "ch", "k", "t", "p", "h"];
943 let result = "";
944 for (let i = 0; i < text.length; i++) {
945 const code = text.charCodeAt(i) - 44032;
946 if (code > -1 && code < 11172) {
947 const cho = Math.floor(code / 588);
948 const jung = Math.floor((code - (cho * 588)) / 28);
949 const jong = code % 28;
950 result += chosung[cho] + jungsung[jung] + jongsung[jong];
951 } else {
952 result += text.charAt(i);
953 }
954 }
955 return result.charAt(0).toUpperCase() + result.slice(1);
956 }
957
958 inputKr.addEventListener('input', function() {
959 if (autoSwitch.checked) inputEn.value = romanize(this.value);
960 });
961
962 inputDepartKr.addEventListener('input', function() {
963 if (autoDepartSwitch.checked) inputDepartEn.value = romanize(this.value);
964 });
965
966 function openEditModal() {
967 // 목적지 탭으로 초기화
968 switchEditTab('dest');
969 departureUnlocked = false;
970 inputKr.value = destKr;
971 inputEn.value = destEn;
972 editModal.style.display = 'flex';
973 setTimeout(() => { editModal.classList.add('show'); }, 10);
974 }
975
976 function closeEditModal(e) {
977 if (e && e.target !== e.currentTarget) return;
978 editModal.classList.remove('show');
979 setTimeout(() => { editModal.style.display = 'none'; }, 300);
980 }
981
982 function switchEditTab(tab) {
983 currentEditTab = tab;
984 const tabDest = document.getElementById('tab-dest');
985 const tabDepart = document.getElementById('tab-depart');
986 const destArea = document.getElementById('edit-dest-area');
987 const departArea = document.getElementById('edit-depart-area');
988
989 if (tab === 'dest') {
990 tabDest.classList.add('active');
991 tabDepart.classList.remove('active');
992 destArea.style.display = 'flex';
993 departArea.style.display = 'none';
994 } else {
995 tabDepart.classList.add('active');
996 tabDest.classList.remove('active');
997 destArea.style.display = 'none';
998 departArea.style.display = 'flex';
999 showDepartureState();
1000 }
1001 }
1002
1003 function showDepartureState() {
1004 const infoText = document.getElementById('departure-info-text');
1005 const confirmBtns = document.getElementById('departure-confirm-buttons');
1006 const editFields = document.getElementById('departure-edit-fields');
1007
1008 if (departureUnlocked) {
1009 // 이미 책갈피 차감됨 → 입력 필드 바로 표시
1010 infoText.innerHTML = '출발지를 입력해주세요.';
1011 confirmBtns.style.display = 'none';
1012 editFields.style.display = 'flex';
1013 editFields.style.flexDirection = 'column';
1014 editFields.style.gap = '10px';
1015 return;
1016 }
1017
1018 if (!isLoggedIn) {
1019 infoText.innerHTML = '출발지 변경은 로그인이 필요합니다.<br><span style="font-size:0.8rem; color:#999;">로그인 페이지로 이동하시겠습니까?</span>';
1020 confirmBtns.style.display = 'flex';
1021 confirmBtns.innerHTML = '<button onclick="closeEditModal()" style="flex:1; background:#eee; color:#333;">취소</button><button onclick="goLoginWithState()" style="flex:1;">로그인하기</button>';
1022 editFields.style.display = 'none';
1023 } else if (userBookmarks < 1) {
1024 infoText.innerHTML = '소지중인 책갈피가 부족해요.<br><span style="font-size:0.8rem; color:#999;">다양한 활동을 통해 책갈피를 얻어봐요.</span>';
1025 confirmBtns.style.display = 'flex';
1026 confirmBtns.innerHTML = '<button onclick="closeEditModal()" style="flex:1;">확인</button>';
1027 editFields.style.display = 'none';
1028 } else {
1029 infoText.innerHTML = '책갈피 <strong>1개</strong>로 출발지를 변경할 수 있어요.<br>변경하시겠어요?<br><span style="font-size:0.8rem; color:#999;">보유 책갈피: ' + userBookmarks + '개</span>';
1030 confirmBtns.style.display = 'flex';
1031 confirmBtns.innerHTML = '<button onclick="closeEditModal()" style="flex:1; background:#eee; color:#333;">취소</button><button onclick="confirmDepartureSpend()" style="flex:1;">변경하기</button>';
1032 editFields.style.display = 'none';
1033 }
1034 }
1035
1036 function confirmDepartureSpend() {
1037 // 책갈피 차감 없이 입력 필드만 표시 (실제 차감은 applyDepartureChange에서)
1038 departureUnlocked = true;
1039 const infoText = document.getElementById('departure-info-text');
1040 const confirmBtns = document.getElementById('departure-confirm-buttons');
1041 const editFields = document.getElementById('departure-edit-fields');
1042 infoText.innerHTML = '출발지를 입력해주세요.<br><span style="font-size:0.8rem; color:#999;">변경 시 책갈피 1개가 차감됩니다.</span>';
1043 confirmBtns.style.display = 'none';
1044 editFields.style.display = 'flex';
1045 editFields.style.flexDirection = 'column';
1046 editFields.style.gap = '10px';
1047 inputDepartKr.value = departKr;
1048 inputDepartEn.value = departEn;
1049 }
1050
1051 function applyDestination() {
1052 const newKr = inputKr.value.trim();
1053 const newEn = inputEn.value.trim();
1054 if (!newKr || !newEn) { showAlert("목적지를 입력해주세요."); return false; }
1055
1056 destKr = newKr;
1057 destEn = newEn;
1058 if(signDestKr) signDestKr.innerText = destKr;
1059 if(signDestEn) signDestEn.innerText = destEn;
1060 if(ticketDestKr) ticketDestKr.innerText = destKr;
1061 if(ticketDestEn) ticketDestEn.innerText = destEn;
1062 updateTicketFontSizes(destKr, destEn);
1063 return true;
1064 }
1065
1066 function applyChange() {
1067 if (applyDestination()) closeEditModal();
1068 }
1069
1070 function applyDestAndSwitchDepart() {
1071 if (applyDestination()) switchEditTab('depart');
1072 }
1073
1074 function applyDepartureChange() {
1075 const newKr = inputDepartKr.value.trim();
1076 const newEn = inputDepartEn.value.trim();
1077 if (!newKr || !newEn) { showAlert("출발지를 입력해주세요."); return; }
1078
1079 // 아무것도 바뀌지 않았으면 책갈피 차감 없이 닫기
1080 if (newKr === departKr && newEn === departEn) {
1081 closeEditModal();
1082 return;
1083 }
1084
1085 // 실제로 변경되었으므로 책갈피 차감
1086 fetch('/hinana/train/spend-bookmark', {
1087 method: 'POST',
1088 headers: { 'Content-Type': 'application/json' },
1089 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1090 })
1091 .then(r => r.json())
1092 .then(data => {
1093 if (data.success) {
1094 userBookmarks = data.remaining;
1095
1096 departKr = newKr;
1097 departEn = newEn;
1098
1099 const signKr = document.getElementById('sign-depart-kr');
1100 const signEn = document.getElementById('sign-depart-en');
1101 if (signKr) signKr.innerText = newKr;
1102 if (signEn) signEn.innerText = newEn;
1103
1104 const ticketKr = document.getElementById('ticket-depart-kr');
1105 const ticketEn = document.getElementById('ticket-depart-en');
1106 if (ticketKr) ticketKr.innerText = newKr;
1107 if (ticketEn) ticketEn.innerText = newEn;
1108
1109 updateDepartureFontSizes(newKr, newEn);
1110 closeEditModal();
1111 } else if (data.needLogin) {
1112 window.location.href = '/login?redirect=/hinana/train';
1113 } else if (data.insufficient) {
1114 showAlert('소지중인 책갈피가 부족해요.');
1115 }
1116 });
1117 }
1118
1119 // 로그인 전 상태 저장 후 리다이렉트
1120 function goLoginWithState() {
1121 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1122 destKr: destKr,
1123 destEn: destEn,
1124 departKr: departKr,
1125 departEn: departEn,
1126 tab: 'depart'
1127 }));
1128 window.location.href = '/login?redirect=/hinana/train';
1129 }
1130
1131 // 페이지 로드 시 저장된 상태 복원
1132 (function restoreState() {
1133 const saved = localStorage.getItem('train_state');
1134 if (!saved) return;
1135 localStorage.removeItem('train_state');
1136
1137 try {
1138 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1139
1140 // 목적지 복원
1141 if (state.destKr && state.destEn) {
1142 destKr = state.destKr;
1143 destEn = state.destEn;
1144 if (signDestKr) signDestKr.innerText = destKr;
1145 if (signDestEn) signDestEn.innerText = destEn;
1146 if (ticketDestKr) ticketDestKr.innerText = destKr;
1147 if (ticketDestEn) ticketDestEn.innerText = destEn;
1148 updateTicketFontSizes(destKr, destEn);
1149 }
1150
1151 // 출발지 복원
1152 if (state.departKr && state.departEn) {
1153 departKr = state.departKr;
1154 departEn = state.departEn;
1155 const sKr = document.getElementById('sign-depart-kr');
1156 const sEn = document.getElementById('sign-depart-en');
1157 const tKr = document.getElementById('ticket-depart-kr');
1158 const tEn = document.getElementById('ticket-depart-en');
1159 if (sKr) sKr.innerText = departKr;
1160 if (sEn) sEn.innerText = departEn;
1161 if (tKr) tKr.innerText = departKr;
1162 if (tEn) tEn.innerText = departEn;
1163 }
1164
1165 // 출발지 탭으로 모달 열기
1166 if (state.tab === 'depart') {
1167 openEditModal();
1168 setTimeout(() => { switchEditTab('depart'); }, 50);
1169 }
1170 } catch(e) {}
1171 })();
1172
1173 const trainPlayer = new SeamlessLoop('/sound/train_noise.mp3', 3);
1174 const rainPlayer = new SeamlessLoop('/sound/rain.mp3', 3);
1175
1176 let bgPositionX = 0; let currentSpeed = 0; const targetMaxSpeed = 20; const acceleration = 0.005; const sceneryChangeDistance = 80000;
1177 let isMoving = false; let isPaused = false; let distanceTraveled = 0;
1178 let totalDistance = 0; // 누적 거리 (초기화되지 않음)
1179 let sceneryChangeCount = 0; // 배경 변경 횟수 (지나간 도시 수)
1180
1181 // KTX 기준 환산: 최고속도 20px/frame = 300km/h
1182 // 60fps 가정: 1시간 = 3600초 * 60fps * 20px = 4,320,000px = 300km
1183 // 따라서 1km = 14,400px
1184 const PIXELS_PER_KM = 14400;
1185
1186 let lastTrainVolume = 1.0;
1187 let lastRainVolume = 1.0;
1188
1189 let h_patrolTimer = null; let h_talkTimer = null; let h_state = 'none';
1190 let m_patrolTimer = null; let m_talkTimer = null; let m_state = 'none';
1191
1192 const maxSceneryCount = Number('<%= totalScenery %>') || 8;
1193 const sceneryImages = [];
1194 for(let i=0; i < maxSceneryCount; i++) { sceneryImages.push(`scenery${i}.png`); }
1195 if (sceneryImages.length === 0) sceneryImages.push('scenery0.png');
1196
1197 const initialIndex = Math.floor(Math.random() * sceneryImages.length);
1198 scenery.style.backgroundImage = `url('/image/${sceneryImages[initialIndex]}')`;
1199
1200 const hinanaLines = ["아하~ 승차권 검사 할까요~?", "불편한 건 없으신가요~ 프로듀서?", "야하~ 오늘 열차는 평화롭네~", "히나나, 여기서 농땡이 쳐도 될까~?", "다음 역은... 어디였더라~?"];
1201 const madokaLines = ["승차권, 확인하겠습니다.", "프로듀서, 여기서 뭐 하세요?", "하아... 귀찮게 하지 마세요.", "떠들지 마세요. 다른 승객에게 방해됩니다.", "도착하면 깨워달라고요? 하..."];
1202
1203 function openTicket() {
1204 const modal = document.getElementById('ticket-modal');
1205 const timeEl = document.getElementById('ticket-time');
1206
1207 const now = new Date();
1208 const month = String(now.getMonth() + 1).padStart(2, '0');
1209 const date = String(now.getDate()).padStart(2, '0');
1210 const hours = String(now.getHours()).padStart(2, '0');
1211 const minutes = String(now.getMinutes()).padStart(2, '0');
1212 timeEl.innerText = `${month}월 ${date}일 ${hours}:${minutes}`;
1213
1214 modal.style.display = 'flex';
1215 setTimeout(() => { modal.classList.add('show'); }, 10);
1216 }
1217
1218 function closeTicket(e) {
1219 const modal = document.getElementById('ticket-modal');
1220 modal.classList.remove('show');
1221 setTimeout(() => { modal.style.display = 'none'; }, 300);
1222 }
1223
1224 function downloadTicket() {
1225 const ticketElement = document.getElementById('capture-target');
1226 const actions = document.getElementById('ticket-actions');
1227
1228 // iOS/iPadOS 감지 (iPadOS 13+ 대응)
1229 const isIOS = /iPhone|iPod/i.test(navigator.userAgent);
1230 const isIPad = /iPad/i.test(navigator.userAgent) ||
1231 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
1232 const isIOSDevice = isIOS || isIPad;
1233
1234 // iOS/iPadOS에서는 스크린샷 안내 방식 사용
1235 if (isIOSDevice) {
1236 showAlert('iPhone/iPad 저장 방법:\n\n1. 스크린샷을 찍어주세요\n (전원 버튼 + 볼륨 ↑)\n\n2. 또는 화면 녹화 후 캡처\n\n잠시 후 승차권이 크게 표시됩니다!');
1237
1238 // 다운로드 버튼 숨기고 승차권만 크게 표시
1239 actions.style.display = 'none';
1240 ticketElement.style.transform = 'scale(1.15)';
1241 ticketElement.style.transition = 'transform 0.3s ease';
1242
1243 // 15초 후 원래대로
1244 setTimeout(() => {
1245 actions.style.display = 'flex';
1246 ticketElement.style.transform = '';
1247 }, 15000);
1248
1249 return;
1250 }
1251
1252 // PC/Android에서는 html2canvas 사용
1253 actions.style.display = 'none';
1254
1255 html2canvas(ticketElement, {
1256 scale: 2,
1257 backgroundColor: null,
1258 windowWidth: 1024,
1259 windowHeight: 768,
1260 useCORS: true,
1261 allowTaint: true,
1262 onclone: (clonedDoc) => {
1263 const clonedTicket = clonedDoc.getElementById('capture-target');
1264 const clonedActions = clonedDoc.getElementById('ticket-actions');
1265
1266 if (clonedActions) clonedActions.remove();
1267
1268 clonedDoc.body.style.transform = 'none';
1269 clonedDoc.body.style.width = '1024px';
1270
1271 clonedTicket.style.cssText = `
1272 width: 650px !important;
1273 max-width: 650px !important;
1274 min-width: 650px !important;
1275 height: auto !important;
1276 transform: none !important;
1277 margin: 0 !important;
1278 box-shadow: none !important;
1279 `;
1280 }
1281 }).then(canvas => {
1282 actions.style.display = 'flex';
1283
1284 try {
1285 const dataURL = canvas.toDataURL('image/png');
1286 const link = document.createElement('a');
1287 link.download = '특급_히나나호_승차권.png';
1288 link.href = dataURL;
1289 link.click();
1290 } catch (err) {
1291 console.error('승차권 다운로드 실패:', err);
1292 showAlert('승차권 저장 중 오류가 발생했습니다.');
1293 }
1294 }).catch(err => {
1295 console.error('승차권 캡처 실패:', err);
1296 actions.style.display = 'flex';
1297 showAlert('승차권 저장 중 오류가 발생했습니다.');
1298 });
1299 }
1300
1301 function copyTicketToClipboard() {
1302 const ticketElement = document.getElementById('capture-target');
1303 const actions = document.getElementById('ticket-actions');
1304 const copyBtn = actions.querySelector('[onclick="copyTicketToClipboard()"]');
1305
1306 // iOS/iPadOS: html2canvas가 회전 CSS 때문에 동작하지 않음
1307 const isIOS = /iPhone|iPod/i.test(navigator.userAgent);
1308 const isIPad = /iPad/i.test(navigator.userAgent) ||
1309 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
1310 if (isIOS || isIPad) {
1311 showAlert('iPhone/iPad에서는 스크린샷으로 저장해주세요.\n(전원 버튼 + 볼륨 ↑)');
1312 actions.style.display = 'none';
1313 ticketElement.style.transform = 'scale(1.15)';
1314 ticketElement.style.transition = 'transform 0.3s ease';
1315 setTimeout(() => {
1316 actions.style.display = 'flex';
1317 ticketElement.style.transform = '';
1318 }, 15000);
1319 return;
1320 }
1321
1322 actions.style.display = 'none';
1323
1324 html2canvas(ticketElement, {
1325 scale: 2,
1326 backgroundColor: null,
1327 windowWidth: 1024,
1328 windowHeight: 768,
1329 useCORS: true,
1330 allowTaint: true,
1331 onclone: (clonedDoc) => {
1332 const clonedTicket = clonedDoc.getElementById('capture-target');
1333 const clonedActions = clonedDoc.getElementById('ticket-actions');
1334 if (clonedActions) clonedActions.remove();
1335 clonedDoc.body.style.transform = 'none';
1336 clonedDoc.body.style.width = '1024px';
1337 clonedTicket.style.cssText = 'width:650px!important;max-width:650px!important;min-width:650px!important;height:auto!important;transform:none!important;margin:0!important;box-shadow:none!important;';
1338 }
1339 }).then(canvas => {
1340 actions.style.display = 'flex';
1341 canvas.toBlob(blob => {
1342 if (!blob) { showAlert('실패했습니다.'); return; }
1343
1344 // 모바일: navigator.share 사용
1345 const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
1346 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
1347
1348 if (isMobile && navigator.canShare) {
1349 const file = new File([blob], '특급_히나나호_승차권.png', { type: 'image/png' });
1350 if (navigator.canShare({ files: [file] })) {
1351 navigator.share({ files: [file] }).then(() => {
1352 copyBtn.className = 'bi bi-clipboard-check ticket-action-btn me-2';
1353 setTimeout(() => { copyBtn.className = 'bi bi-clipboard ticket-action-btn me-2'; }, 2000);
1354 }).catch(() => {}); // 사용자가 공유 취소한 경우
1355 return;
1356 }
1357 }
1358
1359 // PC: 클립보드 복사
1360 if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
1361 navigator.clipboard.write([
1362 new ClipboardItem({ 'image/png': blob })
1363 ]).then(() => {
1364 copyBtn.className = 'bi bi-clipboard-check ticket-action-btn me-2';
1365 setTimeout(() => { copyBtn.className = 'bi bi-clipboard ticket-action-btn me-2'; }, 2000);
1366 }).catch(() => {
1367 showAlert('클립보드 복사에 실패했습니다.');
1368 });
1369 } else {
1370 showAlert('이 브라우저에서는 클립보드 복사를 지원하지 않습니다.');
1371 }
1372 }, 'image/png');
1373 }).catch(err => {
1374 console.error('승차권 캡처 실패:', err);
1375 actions.style.display = 'flex';
1376 showAlert('실패했습니다.');
1377 });
1378 }
1379
1380 let wakeLock = null;
1381
1382 async function requestWakeLock() {
1383 try {
1384 if ('wakeLock' in navigator) {
1385 wakeLock = await navigator.wakeLock.request('screen');
1386 wakeLock.addEventListener('release', () => {});
1387 }
1388 } catch (err) {
1389 console.error(`Wake Lock Error: ${err.name}, ${err.message}`);
1390 }
1391 }
1392
1393 function releaseWakeLock() {
1394 if (wakeLock !== null) {
1395 wakeLock.release().then(() => { wakeLock = null; });
1396 }
1397 }
1398
1399 function gameLoop() {
1400 if (isMoving && !isPaused) {
1401 if (currentSpeed < targetMaxSpeed) currentSpeed += acceleration;
1402 bgPositionX -= currentSpeed;
1403 distanceTraveled += currentSpeed;
1404 totalDistance += currentSpeed; // 누적 거리
1405
1406 const shakeY = (Math.random() - 0.5) * (currentSpeed / targetMaxSpeed) * 3;
1407 scenery.style.backgroundPosition = `${bgPositionX}px calc(50% + ${shakeY}px)`;
1408
1409 if (distanceTraveled > sceneryChangeDistance) {
1410 changeSceneryRandomly();
1411 distanceTraveled = 0;
1412 }
1413
1414 // 하차 확인 모달이 열려있으면 실시간 업데이트
1415 const modal = document.getElementById('exit-confirm-modal');
1416 if (modal.style.display === 'flex') {
1417 updateExitStats();
1418 }
1419 }
1420 requestAnimationFrame(gameLoop);
1421 }
1422 requestAnimationFrame(gameLoop);
1423
1424 function departTrain() {
1425 currentSpeed = 0; distanceTraveled = 0; totalDistance = 0; sceneryChangeCount = 0;
1426 overlay.style.opacity = '0';
1427 setTimeout(() => {
1428 overlay.style.display = 'none';
1429 exitBtn.style.display = 'block'; pauseBtn.style.display = 'block'; fullscreenBtn.style.display = 'block';
1430 soundControls.style.display = 'flex';
1431 }, 1000);
1432
1433 trainPlayer.setVolume(0); trainPlayer.play();
1434 let tVol = 0;
1435 const tInterval = setInterval(() => {
1436 if(tVol < 1.0) { tVol += 0.1; trainPlayer.setVolume(Math.min(tVol, 1.0)); } else clearInterval(tInterval);
1437 }, 200);
1438
1439 rainPlayer.setVolume(0); rainPlayer.play();
1440 let rVol = 0;
1441 const rInterval = setInterval(() => {
1442 if(rVol < 1.0) { rVol += 0.05; rainPlayer.setVolume(Math.min(rVol, 1.0)); } else clearInterval(rInterval);
1443 }, 200);
1444
1445 isMoving = true;
1446 }
1447
1448 function adjustTrainVolume(val) {
1449 const vol = parseFloat(val);
1450 trainPlayer.setVolume(vol);
1451 if(vol > 0) lastTrainVolume = vol;
1452 trainVolIcon.className = '';
1453 if (vol === 0) trainVolIcon.className = 'bi bi-volume-mute-fill sound-icon';
1454 else if (vol < 0.5) trainVolIcon.className = 'bi bi-volume-down-fill sound-icon';
1455 else trainVolIcon.className = 'bi bi-train-front-fill sound-icon';
1456 }
1457
1458 function toggleTrainMute() {
1459 if (trainPlayer.volume > 0) {
1460 lastTrainVolume = trainPlayer.volume;
1461 trainVolSlider.value = 0;
1462 adjustTrainVolume(0);
1463 } else {
1464 trainVolSlider.value = lastTrainVolume;
1465 adjustTrainVolume(lastTrainVolume);
1466 }
1467 }
1468
1469 function adjustRainVolume(val) {
1470 const vol = parseFloat(val);
1471 rainPlayer.setVolume(vol);
1472 if(vol > 0) lastRainVolume = vol;
1473
1474 rainVolIcon.className = '';
1475 if (vol === 0) rainVolIcon.className = 'bi bi-volume-mute-fill sound-icon';
1476 else if (vol < 0.5) rainVolIcon.className = 'bi bi-volume-down-fill sound-icon';
1477 else rainVolIcon.className = 'bi bi-cloud-rain-fill sound-icon';
1478 }
1479
1480 function toggleRainMute() {
1481 if (rainPlayer.volume > 0) {
1482 lastRainVolume = rainPlayer.volume;
1483 rainVolSlider.value = 0;
1484 adjustRainVolume(0);
1485 } else {
1486 rainVolSlider.value = lastRainVolume;
1487 adjustRainVolume(lastRainVolume);
1488 }
1489 }
1490
1491 function changeSceneryRandomly() {
1492 scenery.style.opacity = 0;
1493 setTimeout(() => {
1494 const currentImg = scenery.style.backgroundImage;
1495 let nextImg; do {
1496 const randomIndex = Math.floor(Math.random() * sceneryImages.length);
1497 nextImg = `url('/image/${sceneryImages[randomIndex]}')`;
1498 } while (currentImg.includes(nextImg) && sceneryImages.length > 1);
1499
1500 scenery.style.backgroundImage = nextImg;
1501 scenery.style.opacity = 1;
1502 sceneryChangeCount++; // 도시 카운터 증가
1503 checkCharacterSpawn();
1504 }, 1500);
1505 }
1506
1507 function checkCharacterSpawn() {
1508 if(hinanaImg.style.display === 'block' || madokaImg.style.display === 'block') return;
1509 const rand = Math.random();
1510 if (rand < 0.05) spawnCharacter('hinana');
1511 else if (rand < 0.08) spawnCharacter('madoka');
1512 }
1513
1514 function spawnCharacter(charName) {
1515 setTimeout(() => {
1516 const img = charName === 'hinana' ? hinanaImg : madokaImg;
1517 img.style.display = 'block';
1518 if(charName === 'hinana') h_state = 'patrol'; else m_state = 'patrol';
1519
1520 img.style.opacity = 0;
1521 let op = 0; const fadeIn = setInterval(() => { if (op >= 1) clearInterval(fadeIn); img.style.opacity = op; op += 0.05; }, 50);
1522
1523 if(charName === 'hinana') {
1524 if (h_patrolTimer) clearTimeout(h_patrolTimer);
1525 h_patrolTimer = setTimeout(() => { dismissHinanaWithText(); }, 15000);
1526 } else {
1527 if (m_patrolTimer) clearTimeout(m_patrolTimer);
1528 m_patrolTimer = setTimeout(() => { dismissMadokaWithText(); }, 15000);
1529 }
1530 }, 2000);
1531 }
1532
1533 function talkHinana() {
1534 if (h_patrolTimer) { clearTimeout(h_patrolTimer); h_patrolTimer = null; }
1535 h_state = 'talk';
1536 const randomLine = hinanaLines[Math.floor(Math.random() * hinanaLines.length)];
1537 hinanaBubble.innerText = randomLine; hinanaBubble.style.opacity = '1';
1538 if (window.innerWidth > 991) hinanaBubble.style.bottom = '65vh';
1539 if (!h_talkTimer) {
1540 h_talkTimer = setTimeout(() => { hinanaBubble.style.opacity = '0'; fadeOutChar('hinana'); h_talkTimer = null; }, 6500);
1541 }
1542 }
1543 function dismissHinanaWithText() {
1544 if(hinanaImg.style.display === 'none') return;
1545 h_state = 'leaving';
1546 hinanaBubble.innerText = "이상 없네~"; hinanaBubble.style.opacity = '1';
1547 if (window.innerWidth > 991) hinanaBubble.style.bottom = '65vh';
1548 setTimeout(() => { hinanaBubble.style.opacity = '0'; fadeOutChar('hinana'); }, 1500);
1549 }
1550
1551 function talkMadoka() {
1552 if (m_patrolTimer) { clearTimeout(m_patrolTimer); m_patrolTimer = null; }
1553 m_state = 'talk';
1554 const randomLine = madokaLines[Math.floor(Math.random() * madokaLines.length)];
1555 madokaBubble.innerText = randomLine; madokaBubble.style.opacity = '1';
1556 if (window.innerWidth > 991) madokaBubble.style.bottom = '65vh';
1557 if (!m_talkTimer) {
1558 m_talkTimer = setTimeout(() => { madokaBubble.style.opacity = '0'; fadeOutChar('madoka'); m_talkTimer = null; }, 6500);
1559 }
1560 }
1561 function dismissMadokaWithText() {
1562 if(madokaImg.style.display === 'none') return;
1563 m_state = 'leaving';
1564 madokaBubble.innerText = "이상 없네."; madokaBubble.style.opacity = '1';
1565 if (window.innerWidth > 991) madokaBubble.style.bottom = '65vh';
1566 setTimeout(() => { madokaBubble.style.opacity = '0'; fadeOutChar('madoka'); }, 1500);
1567 }
1568
1569 function fadeOutChar(charName) {
1570 const img = charName === 'hinana' ? hinanaImg : madokaImg;
1571 let op = 1;
1572 const fadeOut = setInterval(() => {
1573 if (op <= 0) {
1574 clearInterval(fadeOut); img.style.display = 'none';
1575 if(charName === 'hinana') { h_state = 'none'; if(h_patrolTimer) clearTimeout(h_patrolTimer); if(h_talkTimer) clearTimeout(h_talkTimer); h_patrolTimer=null; h_talkTimer=null; }
1576 else { m_state = 'none'; if(m_patrolTimer) clearTimeout(m_patrolTimer); if(m_talkTimer) clearTimeout(m_talkTimer); m_patrolTimer=null; m_talkTimer=null; }
1577 }
1578 img.style.opacity = op; op -= 0.05;
1579 }, 50);
1580 }
1581
1582 function togglePause() {
1583 const icon = pauseBtn.querySelector('i');
1584 if (!isPaused) {
1585 isPaused = true;
1586 trainPlayer.pause();
1587 rainPlayer.pause();
1588 icon.className = 'bi bi-play-fill';
1589 if(h_patrolTimer) clearTimeout(h_patrolTimer); if(h_talkTimer) clearTimeout(h_talkTimer);
1590 if(m_patrolTimer) clearTimeout(m_patrolTimer); if(m_talkTimer) clearTimeout(m_talkTimer);
1591 } else {
1592 isPaused = false;
1593 trainPlayer.play();
1594 rainPlayer.play();
1595 icon.className = 'bi bi-pause-fill';
1596
1597 if (h_state === 'patrol') h_patrolTimer = setTimeout(() => { dismissHinanaWithText(); }, 15000);
1598 else if (h_state === 'talk') h_talkTimer = setTimeout(() => { hinanaBubble.style.opacity = '0'; fadeOutChar('hinana'); h_talkTimer = null; }, 6500);
1599 else if (h_state === 'leaving') fadeOutChar('hinana');
1600
1601 if (m_state === 'patrol') m_patrolTimer = setTimeout(() => { dismissMadokaWithText(); }, 15000);
1602 else if (m_state === 'talk') m_talkTimer = setTimeout(() => { madokaBubble.style.opacity = '0'; fadeOutChar('madoka'); m_talkTimer = null; }, 6500);
1603 else if (m_state === 'leaving') fadeOutChar('madoka');
1604 }
1605 }
1606
1607 function toggleFullscreen() {
1608 const icon = fullscreenBtn.querySelector('i');
1609 if (!document.fullscreenElement) {
1610 document.documentElement.requestFullscreen().catch(()=>{});
1611 icon.className = 'bi bi-fullscreen-exit';
1612 } else {
1613 if (document.exitFullscreen) document.exitFullscreen();
1614 icon.className = 'bi bi-arrows-fullscreen';
1615 }
1616 }
1617
1618 document.addEventListener('fullscreenchange', () => {
1619 const icon = fullscreenBtn.querySelector('i');
1620 if (!document.fullscreenElement) {
1621 icon.className = 'bi bi-arrows-fullscreen';
1622 releaseWakeLock();
1623 } else {
1624 icon.className = 'bi bi-fullscreen-exit';
1625 requestWakeLock();
1626 }
1627 });
1628
1629 document.addEventListener('visibilitychange', async () => {
1630 if (document.visibilityState === 'visible' && document.fullscreenElement) {
1631 await requestWakeLock();
1632 }
1633 });
1634
1635 // ========================================================= //
1636 // 하차 확인 모달 함수
1637 // ========================================================= //
1638 function updateExitStats() {
1639 const citiesCount = document.getElementById('exit-cities-count');
1640 const distance = document.getElementById('exit-distance');
1641
1642 // 지나간 도시 수
1643 citiesCount.innerText = `${sceneryChangeCount}개`;
1644
1645 // 거리 계산 (KTX 기준: 최고속도 300km/h)
1646 // 1km = 14,400 픽셀
1647 const distanceKm = (totalDistance / PIXELS_PER_KM).toFixed(1);
1648 distance.innerText = `${distanceKm} km`;
1649 }
1650
1651 function showExitConfirm() {
1652 const modal = document.getElementById('exit-confirm-modal');
1653
1654 // 통계 업데이트
1655 updateExitStats();
1656
1657 // 출발지 → 목적지 표시
1658 const routeEl = document.getElementById('exit-route');
1659 routeEl.innerText = `${departKr} → ${destKr}`;
1660
1661 // 모달 표시
1662 modal.style.display = 'flex';
1663 setTimeout(() => { modal.classList.add('show'); }, 10);
1664 }
1665
1666 function closeExitConfirm(event) {
1667 if (event && event.target !== event.currentTarget) return;
1668 const modal = document.getElementById('exit-confirm-modal');
1669 modal.classList.remove('show');
1670 setTimeout(() => { modal.style.display = 'none'; }, 300);
1671 }
1672
1673 function confirmExit() {
1674 // 하차 시 이벤트 비활성화
1675 isMoving = false;
1676 // Hash 제거 후 이동
1677 window.location.href = '/hinana/lounge';
1678 }
1679
1680 // 라운지 로고 클릭 시 하차 확인 모달 표시
1681 document.getElementById('train-logo').addEventListener('click', function(e) {
1682 if (isMoving) {
1683 e.preventDefault();
1684 showExitConfirm();
1685 }
1686 });
1687
1688 // ========================================================= //
1689 // 빗방울 효과
1690 // ========================================================= //
1691 const rainContainer = document.getElementById('rain-container');
1692
1693 function createRaindrop() {
1694 const drop = document.createElement('div');
1695 drop.className = 'raindrop';
1696
1697 // 무작위 위치
1698 const left = Math.random() * 100;
1699 drop.style.left = `${left}%`;
1700
1701 // 무작위 높이 (길이)
1702 const height = 50 + Math.random() * 100;
1703 drop.style.height = `${height}px`;
1704
1705 // 무작위 애니메이션 지속 시간 (속도)
1706 const duration = 0.5 + Math.random() * 0.5;
1707 drop.style.animationDuration = `${duration}s`;
1708
1709 // 무작위 딜레이
1710 const delay = Math.random() * 2;
1711 drop.style.animationDelay = `${delay}s`;
1712
1713 rainContainer.appendChild(drop);
1714
1715 // 애니메이션 끝난 후 제거
1716 setTimeout(() => {
1717 drop.remove();
1718 }, (duration + delay) * 1000);
1719 }
1720
1721 function createWaterDrop() {
1722 const drop = document.createElement('div');
1723 drop.className = 'water-drop';
1724
1725 // 무작위 위치
1726 const left = Math.random() * 100;
1727 drop.style.left = `${left}%`;
1728 drop.style.top = `${-10 + Math.random() * 20}px`;
1729
1730 // 무작위 크기
1731 const size = 4 + Math.random() * 6;
1732 drop.style.width = `${size}px`;
1733 drop.style.height = `${size}px`;
1734
1735 // 무작위 애니메이션 지속 시간
1736 const duration = 2 + Math.random() * 3;
1737 drop.style.animationDuration = `${duration}s`;
1738
1739 rainContainer.appendChild(drop);
1740
1741 // 애니메이션 끝난 후 제거
1742 setTimeout(() => {
1743 drop.remove();
1744 }, duration * 1000);
1745 }
1746
1747 // 열차 출발 시 빗방울 효과 시작
1748 let rainInterval = null;
1749 let waterDropInterval = null;
1750
1751 function startRainEffect() {
1752 // 빗방울 (가느다란 선) - 더 많이
1753 rainInterval = setInterval(() => {
1754 if (isMoving && !isPaused) {
1755 // 한 번에 2-3개의 빗방울 생성
1756 const count = 2 + Math.floor(Math.random() * 2);
1757 for (let i = 0; i < count; i++) {
1758 createRaindrop();
1759 }
1760 }
1761 }, 50);
1762
1763 // 물방울 (창문 타고 흐르는) - 더 자주
1764 waterDropInterval = setInterval(() => {
1765 if (isMoving && !isPaused) {
1766 createWaterDrop();
1767 }
1768 }, 150);
1769 }
1770
1771 // 페이지 로드 시 빗방울 효과 시작
1772 setTimeout(startRainEffect, 1000);
1773 </script>
1774 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
1775 </body>
1776 </html>
1777