Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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