Lashtw commited on
Commit
4d0e95c
·
verified ·
1 Parent(s): 5904ddb

Upload 2 files

Browse files
Files changed (2) hide show
  1. congruence_detective.html +280 -7
  2. skyscraper.html +133 -24
congruence_detective.html CHANGED
@@ -195,6 +195,29 @@
195
  pointer-events: none;
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  /* Scanlines */
199
  .scanlines {
200
  background: linear-gradient(to bottom,
@@ -247,6 +270,109 @@
247
  flex-direction: column;
248
  justify-content: flex-start;
249
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  </style>
251
  </head>
252
 
@@ -314,6 +440,18 @@
314
  </div>
315
  </div>
316
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <script>
318
  // Game Script
319
  let playerName = localStorage.getItem('player_nickname') || '��鳥探員';
@@ -321,6 +459,124 @@
321
  // Initial Script Placeholder - populated dynamically
322
  let SCRIPT = [];
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  class GameEngine {
325
  constructor() {
326
  this.step = 0;
@@ -1320,16 +1576,14 @@
1320
 
1321
  quizLayer.classList.remove('hidden');
1322
 
1323
- // Integrated layout matching dialogue style exactly
1324
-
1325
  // Integrated layout matching dialogue style exactly
1326
  quizContent.innerHTML = `
1327
  <div class="dialogue-name">資深警探 <span class="text-xs text-slate-500 ml-2 tracking-normal">// AUTHENTICATION REQUIRED</span></div>
1328
  <div class="dialogue-text mb-4">${data.question}</div>
1329
 
1330
  <div class="flex gap-4 w-full mt-auto items-end">
1331
- <div class="text-fuchsia-500 font-tech text-xl animate-pulse">></div>
1332
- <input type="text" id="quiz-input" class="flex-1 bg-transparent border-b-2 border-fuchsia-500/50 text-white text-xl p-2 focus:outline-none focus:border-fuchsia-400 font-mono tracking-wider placeholder-slate-600" placeholder="輸入答案..." autocomplete="off">
1333
  <button id="quiz-submit" class="bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold px-8 py-2 rounded clip-path-slant text-lg font-tech tracking-widest border border-fuchsia-400/50 transition-all shadow-lg shadow-fuchsia-500/20">CONFIRM</button>
1334
  </div>
1335
  <div id="quiz-feedback" class="absolute top-6 right-8 font-tech text-xl font-bold"></div>
@@ -1339,8 +1593,6 @@
1339
  const btn = document.getElementById('quiz-submit');
1340
  const feedback = document.getElementById('quiz-feedback');
1341
 
1342
- input.focus();
1343
-
1344
  const checkAnswer = () => {
1345
  const val = input.value.trim();
1346
  if (data.answer.includes(val)) {
@@ -1349,6 +1601,7 @@
1349
  feedback.innerText = 'ACCESS GRANTED';
1350
  input.classList.add('text-green-400');
1351
  input.disabled = true;
 
1352
 
1353
  setTimeout(() => {
1354
  quizLayer.classList.add('hidden');
@@ -1369,13 +1622,33 @@
1369
  }, 500);
1370
 
1371
  // Show persistent wrong message
1372
- feedback.className = 'text-red-400 text-lg font-bold'; // Increased size for visibility
1373
  feedback.innerText = data.wrongMsg;
1374
  }
1375
  };
1376
 
1377
  btn.onclick = checkAnswer;
1378
  input.onkeypress = (e) => { if (e.key === 'Enter') checkAnswer(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1379
  }
1380
 
1381
  // Add shake animation style dynamically
 
195
  pointer-events: none;
196
  }
197
 
198
+ /* 平板/手機裝置:警探縮小並置於圖片後方 */
199
+ @media (max-width: 1024px),
200
+ (pointer: coarse) {
201
+ .character-sprite {
202
+ height: clamp(250px, 40vh, 450px);
203
+ z-index: 5;
204
+ /* 在其他圖片(z-index:10)之後 */
205
+ bottom: 60px;
206
+ left: -15px;
207
+ opacity: 0.85;
208
+ }
209
+ }
210
+
211
+ @media (max-width: 768px) {
212
+ .character-sprite {
213
+ height: clamp(180px, 30vh, 300px);
214
+ z-index: 5;
215
+ bottom: 50px;
216
+ left: -10px;
217
+ opacity: 0.75;
218
+ }
219
+ }
220
+
221
  /* Scanlines */
222
  .scanlines {
223
  background: linear-gradient(to bottom,
 
270
  flex-direction: column;
271
  justify-content: flex-start;
272
  }
273
+
274
+ /* ====== Virtual Keypad ====== */
275
+ #virtual-keypad {
276
+ position: fixed;
277
+ bottom: 20px;
278
+ right: 20px;
279
+ left: auto;
280
+ transform: translateY(120%);
281
+ z-index: 1000;
282
+ background: rgba(15, 5, 25, 0.95);
283
+ backdrop-filter: blur(10px);
284
+ -webkit-backdrop-filter: blur(10px);
285
+ border: 1px solid rgba(217, 70, 239, 0.4);
286
+ border-radius: 20px;
287
+ padding: 16px;
288
+ display: grid;
289
+ grid-template-columns: repeat(2, 1fr);
290
+ gap: 10px;
291
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 20px rgba(217, 70, 239, 0.15);
292
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
293
+ touch-action: none;
294
+ max-width: 200px;
295
+ }
296
+
297
+ #virtual-keypad.active {
298
+ transform: translateY(0);
299
+ }
300
+
301
+ #virtual-keypad.dragging {
302
+ transition: none;
303
+ transform: none;
304
+ }
305
+
306
+ .keypad-handle {
307
+ grid-column: span 2;
308
+ height: 20px;
309
+ margin-bottom: 4px;
310
+ background: rgba(255, 255, 255, 0.15);
311
+ border-radius: 10px;
312
+ cursor: grab;
313
+ display: flex;
314
+ justify-content: center;
315
+ align-items: center;
316
+ }
317
+
318
+ .keypad-handle::after {
319
+ content: '';
320
+ width: 40px;
321
+ height: 4px;
322
+ background: rgba(255, 255, 255, 0.35);
323
+ border-radius: 2px;
324
+ }
325
+
326
+ .keypad-btn {
327
+ width: 70px;
328
+ height: 60px;
329
+ border-radius: 12px;
330
+ background: rgba(30, 10, 50, 0.7);
331
+ border: 1px solid rgba(217, 70, 239, 0.25);
332
+ color: white;
333
+ font-size: 22px;
334
+ font-weight: bold;
335
+ font-family: 'Noto Sans TC', sans-serif;
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ cursor: pointer;
340
+ transition: all 0.15s;
341
+ user-select: none;
342
+ }
343
+
344
+ .keypad-btn:active {
345
+ transform: scale(0.93);
346
+ background: rgba(217, 70, 239, 0.25);
347
+ border-color: rgba(217, 70, 239, 0.6);
348
+ }
349
+
350
+ .keypad-btn.action {
351
+ background: rgba(15, 5, 25, 0.8);
352
+ border-color: rgba(217, 70, 239, 0.5);
353
+ color: #d946ef;
354
+ }
355
+
356
+ .keypad-btn.submit {
357
+ background: rgba(217, 70, 239, 0.7);
358
+ color: white;
359
+ grid-column: span 2;
360
+ width: 100%;
361
+ height: 50px;
362
+ font-size: 16px;
363
+ margin-top: 4px;
364
+ letter-spacing: 2px;
365
+ }
366
+
367
+ .keypad-btn.submit:active {
368
+ background: rgba(217, 70, 239, 0.9);
369
+ }
370
+
371
+ /* quiz-input 被選中時的高亮 */
372
+ #quiz-input.keypad-active {
373
+ border-color: #d946ef;
374
+ box-shadow: 0 0 12px rgba(217, 70, 239, 0.4);
375
+ }
376
  </style>
377
  </head>
378
 
 
440
  </div>
441
  </div>
442
 
443
+ <!-- Virtual Keypad HTML -->
444
+ <div id="virtual-keypad" onclick="event.stopPropagation()">
445
+ <div class="keypad-handle"></div>
446
+ <div class="keypad-btn" onclick="keypad.input('S')">S</div>
447
+ <div class="keypad-btn" onclick="keypad.input('A')">A</div>
448
+ <div class="keypad-btn" onclick="keypad.input('邊')">邊</div>
449
+ <div class="keypad-btn" onclick="keypad.input('角')">角</div>
450
+ <div class="keypad-btn action" onclick="keypad.backspace()">⌫</div>
451
+ <div class="keypad-btn action" onclick="keypad.clear()">清除</div>
452
+ <div class="keypad-btn submit" onclick="keypad.submit()">確認 ✓</div>
453
+ </div>
454
+
455
  <script>
456
  // Game Script
457
  let playerName = localStorage.getItem('player_nickname') || '��鳥探員';
 
459
  // Initial Script Placeholder - populated dynamically
460
  let SCRIPT = [];
461
 
462
+ // ====== Virtual Keypad System ======
463
+ const keypad = {
464
+ element: null,
465
+ targetInput: null,
466
+ submitCallback: null,
467
+ init: function () {
468
+ this.element = document.getElementById('virtual-keypad');
469
+ if (!this.element) return;
470
+
471
+ // Prevent click bubbling
472
+ this.element.addEventListener('click', (e) => e.stopPropagation());
473
+
474
+ // Auto-close when clicking outside
475
+ document.addEventListener('click', (e) => {
476
+ if (this.element.classList.contains('active') &&
477
+ !this.element.contains(e.target) &&
478
+ e.target !== this.targetInput &&
479
+ !e.target.classList.contains('keypad-btn')) {
480
+ this.close();
481
+ }
482
+ });
483
+
484
+ // --- Drag Logic ---
485
+ const handle = this.element.querySelector('.keypad-handle');
486
+ this.isDragging = false;
487
+ this.offsetX = 0;
488
+ this.offsetY = 0;
489
+
490
+ const startDrag = (e) => {
491
+ if (e.type === 'touchstart' && e.cancelable) e.preventDefault();
492
+ this.isDragging = true;
493
+ this.element.classList.add('dragging');
494
+ const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
495
+ const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;
496
+ const rect = this.element.getBoundingClientRect();
497
+ this.offsetX = clientX - rect.left;
498
+ this.offsetY = clientY - rect.top;
499
+ document.addEventListener(e.type.includes('mouse') ? 'mousemove' : 'touchmove', doDrag, { passive: false });
500
+ document.addEventListener(e.type.includes('mouse') ? 'mouseup' : 'touchend', endDrag);
501
+ };
502
+
503
+ const doDrag = (e) => {
504
+ if (!this.isDragging) return;
505
+ e.preventDefault();
506
+ const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
507
+ const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;
508
+ this.element.style.transform = 'none';
509
+ this.element.style.bottom = 'auto';
510
+ this.element.style.right = 'auto';
511
+ this.element.style.left = (clientX - this.offsetX) + 'px';
512
+ this.element.style.top = (clientY - this.offsetY) + 'px';
513
+ };
514
+
515
+ const endDrag = (e) => {
516
+ this.isDragging = false;
517
+ this.element.classList.remove('dragging');
518
+ document.removeEventListener('mousemove', doDrag);
519
+ document.removeEventListener('touchmove', doDrag);
520
+ document.removeEventListener('mouseup', endDrag);
521
+ document.removeEventListener('touchend', endDrag);
522
+ };
523
+
524
+ if (handle) {
525
+ handle.addEventListener('mousedown', startDrag);
526
+ handle.addEventListener('touchstart', startDrag, { passive: false });
527
+ handle.style.cursor = 'grab';
528
+ }
529
+ },
530
+ open: function (inputElement, onSubmit) {
531
+ this.targetInput = inputElement;
532
+ this.submitCallback = onSubmit || null;
533
+ this.element.classList.add('active');
534
+ if (inputElement) {
535
+ inputElement.classList.add('keypad-active');
536
+ // 阻止系統鍵盤彈出
537
+ inputElement.setAttribute('readonly', 'readonly');
538
+ inputElement.setAttribute('inputmode', 'none');
539
+ }
540
+ },
541
+ close: function () {
542
+ this.element.classList.remove('active');
543
+ if (this.targetInput) {
544
+ this.targetInput.classList.remove('keypad-active');
545
+ this.targetInput.removeAttribute('readonly');
546
+ this.targetInput.removeAttribute('inputmode');
547
+ this.targetInput = null;
548
+ }
549
+ this.submitCallback = null;
550
+ },
551
+ input: function (val) {
552
+ if (!this.targetInput) return;
553
+ this.targetInput.value += val;
554
+ // 視覺回饋
555
+ this.targetInput.focus();
556
+ },
557
+ backspace: function () {
558
+ if (!this.targetInput) return;
559
+ this.targetInput.value = this.targetInput.value.slice(0, -1);
560
+ },
561
+ clear: function () {
562
+ if (!this.targetInput) return;
563
+ this.targetInput.value = '';
564
+ },
565
+ submit: function () {
566
+ if (this.submitCallback) {
567
+ this.submitCallback();
568
+ }
569
+ }
570
+ };
571
+
572
+ // 偵測是否為觸控裝置(平板/手機)
573
+ const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
574
+
575
+ // Initialize keypad
576
+ document.addEventListener('DOMContentLoaded', () => {
577
+ keypad.init();
578
+ });
579
+
580
  class GameEngine {
581
  constructor() {
582
  this.step = 0;
 
1576
 
1577
  quizLayer.classList.remove('hidden');
1578
 
 
 
1579
  // Integrated layout matching dialogue style exactly
1580
  quizContent.innerHTML = `
1581
  <div class="dialogue-name">資深警探 <span class="text-xs text-slate-500 ml-2 tracking-normal">// AUTHENTICATION REQUIRED</span></div>
1582
  <div class="dialogue-text mb-4">${data.question}</div>
1583
 
1584
  <div class="flex gap-4 w-full mt-auto items-end">
1585
+ <div class="text-fuchsia-500 font-tech text-xl animate-pulse">&gt;</div>
1586
+ <input type="text" id="quiz-input" class="flex-1 bg-transparent border-b-2 border-fuchsia-500/50 text-white text-xl p-2 focus:outline-none focus:border-fuchsia-400 font-mono tracking-wider placeholder-slate-600" placeholder="輸入答案..." autocomplete="off" inputmode="none">
1587
  <button id="quiz-submit" class="bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold px-8 py-2 rounded clip-path-slant text-lg font-tech tracking-widest border border-fuchsia-400/50 transition-all shadow-lg shadow-fuchsia-500/20">CONFIRM</button>
1588
  </div>
1589
  <div id="quiz-feedback" class="absolute top-6 right-8 font-tech text-xl font-bold"></div>
 
1593
  const btn = document.getElementById('quiz-submit');
1594
  const feedback = document.getElementById('quiz-feedback');
1595
 
 
 
1596
  const checkAnswer = () => {
1597
  const val = input.value.trim();
1598
  if (data.answer.includes(val)) {
 
1601
  feedback.innerText = 'ACCESS GRANTED';
1602
  input.classList.add('text-green-400');
1603
  input.disabled = true;
1604
+ keypad.close(); // 關閉小鍵盤
1605
 
1606
  setTimeout(() => {
1607
  quizLayer.classList.add('hidden');
 
1622
  }, 500);
1623
 
1624
  // Show persistent wrong message
1625
+ feedback.className = 'text-red-400 text-lg font-bold';
1626
  feedback.innerText = data.wrongMsg;
1627
  }
1628
  };
1629
 
1630
  btn.onclick = checkAnswer;
1631
  input.onkeypress = (e) => { if (e.key === 'Enter') checkAnswer(); };
1632
+
1633
+ // 自動開啟小鍵盤(觸控裝置)或點擊輸入框時開啟
1634
+ if (isTouchDevice) {
1635
+ // 觸控裝置自動開啟
1636
+ setTimeout(() => {
1637
+ keypad.open(input, checkAnswer);
1638
+ }, 300);
1639
+ }
1640
+
1641
+ // 點擊輸入框也可以開啟小鍵盤
1642
+ input.addEventListener('click', (e) => {
1643
+ e.preventDefault();
1644
+ keypad.open(input, checkAnswer);
1645
+ });
1646
+ input.addEventListener('focus', (e) => {
1647
+ if (isTouchDevice) {
1648
+ e.preventDefault();
1649
+ keypad.open(input, checkAnswer);
1650
+ }
1651
+ });
1652
  }
1653
 
1654
  // Add shake animation style dynamically
skyscraper.html CHANGED
@@ -798,21 +798,20 @@
798
  });
799
  const avg = totalErr / this.s.markerTs.length;
800
  if (this.s.phase === 'tutorial') {
801
- if (avg < 5) {
802
  this._showPhaseModal('✅', '教學完成!', '垂直距離處處相等 = 平行!\n確認距離一致就代表兩條線平行', '確認,繼續 →', null);
803
  this.dom.tbtn.onclick = () => { this.dom.tm.classList.add('hidden'); this.s.lvIdx++; this._genLevel() }
804
  }
805
- else this._showToast('還不夠精確,再調整一下!', false);
806
  return;
807
  }
808
  if (this.s.fixMode) {
809
- if (avg < 4) { this._onCorrect(80) }
810
- else if (avg < 10) { this._onCorrect(Math.max(20, 60 - Math.floor(avg * 4))) }
811
- else this._showToast('還不夠精確,再調整!', false);
812
  return;
813
  }
814
- if (avg < 4) this._onCorrect(100); else if (avg < 10) this._onCorrect(Math.max(30, 80 - Math.floor(avg * 5)));
815
- else this._showToast('垂直距離不一致,再調整', false);
816
  }
817
 
818
  judgeAnswer(says) {
@@ -905,24 +904,134 @@
905
  if (this.s.playing) { this._drawBld(ctx, this.s.bL, 'left', f); this._drawBld(ctx, this.s.bR, 'right', f); this._drawScene(ctx, f) }
906
  }
907
  _drawBld(ctx, b, side, f) {
908
- const gr = ctx.createLinearGradient(b.x, b.y, b.x + b.w, b.y + b.h);
909
- gr.addColorStop(0, '#0f2818'); gr.addColorStop(1, '#081a0e');
910
- ctx.fillStyle = gr; ctx.beginPath(); const t = 3;
911
- ctx.moveTo(b.x + t, b.y); ctx.lineTo(b.x + b.w - t, b.y); ctx.lineTo(b.x + b.w, b.y + b.h); ctx.lineTo(b.x, b.y + b.h); ctx.closePath(); ctx.fill();
912
- ctx.strokeStyle = 'rgba(34,197,94,.12)'; ctx.lineWidth = 1; ctx.stroke();
913
- ctx.fillStyle = 'rgba(6,182,212,.3)'; ctx.fillRect(b.x + t, b.y, b.w - t * 2, 3);
914
- const wC = Math.floor(b.w / 20), wR = Math.floor(b.h / 24), ws = 8;
915
- const gx = (b.w - wC * ws) / (wC + 1), gy = (b.h - wR * ws) / (wR + 1);
916
- for (let r = 0; r < wR; r++)for (let c = 0; c < wC; c++) {
917
- const wx = b.x + gx * (c + 1) + ws * c, wy = b.y + gy * (r + 1) + ws * r;
918
- const lit = Math.sin(f * .005 + r * 3.7 + c * 7.1) > -.3;
919
- ctx.fillStyle = lit ? `rgba(251,191,36,${(Math.sin(f * .01 + r + c * 2) * .3 + .7) * .35})` : 'rgba(30,41,59,.7)';
920
- ctx.fillRect(wx, wy, ws, ws);
 
 
 
 
 
 
 
 
 
 
 
 
921
  }
922
- if (side === 'right') {
923
- const ax = b.x + b.w / 2; ctx.strokeStyle = '#475569'; ctx.lineWidth = 2;
924
- ctx.beginPath(); ctx.moveTo(ax, b.y); ctx.lineTo(ax, b.y - 25); ctx.stroke();
925
- if (Math.sin(f * .06) > 0) { ctx.beginPath(); ctx.arc(ax, b.y - 25, 3, 0, Math.PI * 2); ctx.fillStyle = '#ef4444'; ctx.shadowBlur = 8; ctx.shadowColor = '#ef4444'; ctx.fill(); ctx.shadowBlur = 0 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  }
927
  }
928
  _drawScene(ctx, f) {
 
798
  });
799
  const avg = totalErr / this.s.markerTs.length;
800
  if (this.s.phase === 'tutorial') {
801
+ if (avg < 2) {
802
  this._showPhaseModal('✅', '教學完成!', '垂直距離處處相等 = 平行!\n確認距離一致就代表兩條線平行', '確認,繼續 →', null);
803
  this.dom.tbtn.onclick = () => { this.dom.tm.classList.add('hidden'); this.s.lvIdx++; this._genLevel() }
804
  }
805
+ else this._showToast('還不夠精確!各處的垂直距離必須完全相等才是平行', false);
806
  return;
807
  }
808
  if (this.s.fixMode) {
809
+ if (avg < 1.5) { this._onCorrect(80) }
810
+ else this._showToast('還不夠精確!垂直距離必須完全一致才算平行', false);
 
811
  return;
812
  }
813
+ if (avg < 1.5) this._onCorrect(100);
814
+ else this._showToast('垂直距離不一致,還不是完全平行!再調整', false);
815
  }
816
 
817
  judgeAnswer(says) {
 
904
  if (this.s.playing) { this._drawBld(ctx, this.s.bL, 'left', f); this._drawBld(ctx, this.s.bR, 'right', f); this._drawScene(ctx, f) }
905
  }
906
  _drawBld(ctx, b, side, f) {
907
+ // === 施工中建築 ===
908
+ // 鋼骨框架背景(半透明深色)
909
+ ctx.fillStyle = 'rgba(15, 40, 24, 0.4)';
910
+ ctx.fillRect(b.x, b.y, b.w, b.h);
911
+
912
+ // 垂直鋼骨柱(左右兩根主柱 + 中間支撐柱)
913
+ const steelColor = '#475569';
914
+ const steelHighlight = '#64748b';
915
+ const pillarW = 4;
916
+ ctx.fillStyle = steelColor;
917
+ ctx.fillRect(b.x, b.y, pillarW, b.h); // 左柱
918
+ ctx.fillRect(b.x + b.w - pillarW, b.y, pillarW, b.h); // 右柱
919
+ ctx.fillRect(b.x + b.w / 2 - pillarW / 2, b.y, pillarW, b.h); // 中柱
920
+
921
+ // 水平鋼骨樓板(每隔一段距離一條)
922
+ const floorGap = Math.max(30, b.h / 10);
923
+ const numFloors = Math.floor(b.h / floorGap);
924
+ for (let i = 0; i <= numFloors; i++) {
925
+ const fy = b.y + i * floorGap;
926
+ if (fy > b.y + b.h) break;
927
+ ctx.fillStyle = steelColor;
928
+ ctx.fillRect(b.x, fy, b.w, 3);
929
+ // 高亮線
930
+ ctx.fillStyle = steelHighlight;
931
+ ctx.fillRect(b.x, fy, b.w, 1);
932
  }
933
+
934
+ // 交叉鋼骨支撐(X 型斜撐,每隔一層交替方向)
935
+ ctx.strokeStyle = 'rgba(100, 116, 139, 0.5)';
936
+ ctx.lineWidth = 1.5;
937
+ for (let i = 0; i < numFloors; i++) {
938
+ const y1 = b.y + i * floorGap;
939
+ const y2 = Math.min(b.y + (i + 1) * floorGap, b.y + b.h);
940
+ // 左半 X
941
+ if (i % 2 === 0) {
942
+ ctx.beginPath();
943
+ ctx.moveTo(b.x, y1); ctx.lineTo(b.x + b.w / 2, y2);
944
+ ctx.stroke();
945
+ ctx.beginPath();
946
+ ctx.moveTo(b.x + b.w / 2, y1); ctx.lineTo(b.x, y2);
947
+ ctx.stroke();
948
+ } else {
949
+ // 右半 X
950
+ ctx.beginPath();
951
+ ctx.moveTo(b.x + b.w / 2, y1); ctx.lineTo(b.x + b.w, y2);
952
+ ctx.stroke();
953
+ ctx.beginPath();
954
+ ctx.moveTo(b.x + b.w, y1); ctx.lineTo(b.x + b.w / 2, y2);
955
+ ctx.stroke();
956
+ }
957
+ }
958
+
959
+ // 鷹架(外側細格線)
960
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)';
961
+ ctx.lineWidth = 1;
962
+ const scaffoldGap = 15;
963
+ for (let sy = b.y; sy < b.y + b.h; sy += scaffoldGap) {
964
+ ctx.beginPath();
965
+ ctx.moveTo(b.x - 6, sy); ctx.lineTo(b.x, sy);
966
+ ctx.stroke();
967
+ ctx.beginPath();
968
+ ctx.moveTo(b.x + b.w, sy); ctx.lineTo(b.x + b.w + 6, sy);
969
+ ctx.stroke();
970
+ }
971
+ // 鷹架垂直線
972
+ ctx.beginPath(); ctx.moveTo(b.x - 6, b.y); ctx.lineTo(b.x - 6, b.y + b.h); ctx.stroke();
973
+ ctx.beginPath(); ctx.moveTo(b.x + b.w + 6, b.y); ctx.lineTo(b.x + b.w + 6, b.y + b.h); ctx.stroke();
974
+
975
+ // 安全網(頂部附近,半透明綠色區域)
976
+ const netH = Math.min(floorGap * 2, b.h * 0.2);
977
+ ctx.fillStyle = 'rgba(34, 197, 94, 0.06)';
978
+ ctx.fillRect(b.x, b.y, b.w, netH);
979
+ // 安全網格線
980
+ ctx.strokeStyle = 'rgba(34, 197, 94, 0.15)';
981
+ ctx.lineWidth = 0.5;
982
+ for (let nx = b.x; nx < b.x + b.w; nx += 8) {
983
+ ctx.beginPath(); ctx.moveTo(nx, b.y); ctx.lineTo(nx, b.y + netH); ctx.stroke();
984
+ }
985
+ for (let ny = b.y; ny < b.y + netH; ny += 8) {
986
+ ctx.beginPath(); ctx.moveTo(b.x, ny); ctx.lineTo(b.x + b.w, ny); ctx.stroke();
987
+ }
988
+
989
+ // 未完成頂部(鋸齒狀不規則頂端)
990
+ ctx.fillStyle = steelColor;
991
+ const toothW = b.w / 5;
992
+ for (let tx = b.x; tx < b.x + b.w; tx += toothW) {
993
+ const th = 5 + Math.abs(Math.sin(tx * 0.7)) * 12;
994
+ ctx.fillRect(tx, b.y - th, Math.min(pillarW, toothW), th);
995
+ }
996
+
997
+ // 起重機塔吊
998
+ const craneX = side === 'left' ? b.x + b.w * 0.3 : b.x + b.w * 0.7;
999
+ const craneTopY = b.y - 40;
1000
+ // 塔身
1001
+ ctx.strokeStyle = '#f97316';
1002
+ ctx.lineWidth = 3;
1003
+ ctx.beginPath(); ctx.moveTo(craneX, b.y); ctx.lineTo(craneX, craneTopY); ctx.stroke();
1004
+ // 吊臂(水平)
1005
+ const armLen = side === 'left' ? b.w * 1.2 : -b.w * 1.2;
1006
+ ctx.lineWidth = 2;
1007
+ ctx.beginPath(); ctx.moveTo(craneX, craneTopY); ctx.lineTo(craneX + armLen, craneTopY); ctx.stroke();
1008
+ // 支撐鋼索
1009
+ ctx.strokeStyle = 'rgba(249, 115, 22, 0.4)'; ctx.lineWidth = 1;
1010
+ ctx.beginPath(); ctx.moveTo(craneX, craneTopY - 8); ctx.lineTo(craneX + armLen * 0.7, craneTopY); ctx.stroke();
1011
+ ctx.beginPath(); ctx.moveTo(craneX, craneTopY - 8); ctx.lineTo(craneX + armLen * 0.3, craneTopY); ctx.stroke();
1012
+ // 吊索 + 吊鉤(動態擺動)
1013
+ const hookX = craneX + armLen * (0.5 + Math.sin(f * 0.02) * 0.15);
1014
+ const hookY = craneTopY + 20 + Math.sin(f * 0.03) * 5;
1015
+ ctx.strokeStyle = 'rgba(249, 115, 22, 0.6)'; ctx.lineWidth = 1;
1016
+ ctx.beginPath(); ctx.moveTo(hookX, craneTopY); ctx.lineTo(hookX, hookY); ctx.stroke();
1017
+ // 吊鉤
1018
+ ctx.fillStyle = '#f97316';
1019
+ ctx.beginPath(); ctx.arc(hookX, hookY, 3, 0, Math.PI * 2); ctx.fill();
1020
+ ctx.beginPath(); ctx.arc(hookX, hookY + 5, 5, 0, Math.PI); ctx.stroke();
1021
+
1022
+ // 閃爍警示燈(頂部)
1023
+ if (Math.sin(f * .06) > 0) {
1024
+ ctx.beginPath(); ctx.arc(craneX, craneTopY - 8, 3, 0, Math.PI * 2);
1025
+ ctx.fillStyle = '#ef4444'; ctx.shadowBlur = 10; ctx.shadowColor = '#ef4444'; ctx.fill(); ctx.shadowBlur = 0;
1026
+ }
1027
+
1028
+ // 底部建材堆放(矩形鋼筋堆)
1029
+ ctx.fillStyle = 'rgba(148, 163, 184, 0.3)';
1030
+ const stackY = b.y + b.h - 15;
1031
+ for (let sx = 0; sx < 3; sx++) {
1032
+ ctx.fillRect(b.x + 8 + sx * (b.w / 4), stackY, b.w / 5, 10);
1033
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)'; ctx.lineWidth = 0.5;
1034
+ ctx.strokeRect(b.x + 8 + sx * (b.w / 4), stackY, b.w / 5, 10);
1035
  }
1036
  }
1037
  _drawScene(ctx, f) {