PageSourceSearch

https://doors-to-manual.netlify.app/js/scenes/CabinScene.js

js doors-to-manual.netlify.app collected 2026-10-03 10:39:48 UTC 39,678 bytes, 1,266 lines download raw bytes

1// =============================================================================
2// PHASER SCENE — CabinScene
3// =============================================================================
4class CabinScene extends Phaser.Scene {
5  constructor() {
6    super({ key: "CabinScene" });
7  }
8
9  preload() {
10    ["female-crew", "male-crew"].forEach((g) => {
11      ["south", "north", "east", "west"].forEach((d) => {
12        this.load.image(`${g}-${d}`, `sprites/${g}-${d}.png`);
13      });
14    });
15    this.load.image("bubble_oj", "sprites/bubble_oj.png");
16    this.load.image("bubble_water", "sprites/bubble_water.png");
17    this.load.image("bubble_wine", "sprites/bubble_wine.png");
18    this.load.image("bubble_sleeping", "sprites/bubble_sleeping.png");
19    this.load.image("bubble_emptycup", "sprites/bubble_emptycup.png");
20    this.load.audio("landing_announcement", "audio/landing_announcement.mp3");
21    this.load.audio("cabin_ambience", "audio/cabin_ambience.mp3");
22    this.load.audio("sfx_correct", "audio/sfx_correct.mp3");
23    this.load.audio("sfx_wrong", "audio/sfx_wrong.mp3");
24    this.load.audio("sfx_collect", "audio/sfx_collect.mp3");
25    this.load.audio("sfx_seatbelt_chime", "audio/sfx_seatbelt_chime.mp3");
26    this.load.audio("turbulence", "audio/turbulence.mp3");
27    this.load.on("loaderror", (file) => {
28      console.warn("Asset failed to load:", file.key);
29    });
30    this.load.atlas(
31      "passengers",
32      "sprites/passengers.png",
33      "sprites/passengers.json",
34    );
35    generateTextures(this);
36  }
37
38  create() {
39    this.ensureFallbackSprites();
40    this.routeKey = window.selectedRoute || "KUL";
41    this.route = ROUTES[this.routeKey] || ROUTES.KUL;
42    this.paxMap = buildPaxMap(this.routeKey);
43    this.score = 0;
44    this.remainingSec = this.route.timerSeconds;
45    this.phase = "idle";
46    this.facing = "east";
47    this.selectedSeatIdx = null;
48    this.selectedServiceTarget = null;
49    this.serviceSideChosen = false;
50    this.promptState = null;
51
52    // -------------------------------------------------------------------------
53    // FIX — Issue 3: Reset all persistent DOM HUD elements on every new game.
54    // The #hud-timer "is-warning" CSS class (red/pulsing) is only ever added,
55    // never removed — so it carries over into subsequent playthroughs.
56    // We also reset the timer label since Phaser scene restarts do not touch
57    // DOM elements outside the canvas.
58    // -------------------------------------------------------------------------
59    const timerEl = document.getElementById("hud-timer");
60    if (timerEl) {
61      timerEl.classList.remove("is-warning");
62      timerEl.textContent = "04:00";
63    }
64    const scoreEl = document.getElementById("hud-score");
65    if (scoreEl) scoreEl.textContent = "SCORE: 0";
66    const phaseEl = document.getElementById("hud-phase");
67    if (phaseEl) phaseEl.textContent = "PHASE: BOARDING";
68    const hintEl = document.getElementById("hint-bar");
69    if (hintEl) hintEl.textContent = "SPACEBAR — Begin service";
70    const armedEl = document.getElementById("hud-armed");
71    if (armedEl) {
72      armedEl.textContent = "DOORS TO ARMED";
73      armedEl.style.color = "#ff4444";
74    }
75
76    this.add.rectangle(CW / 2, CH / 2, CW, CH, C.bg);
77    this.drawFuselage();
78    this.drawGalley();
79    this.drawAisle();
80    this.drawCabin();
81    this.drawCockpit();
82    this.aisleTint = this.add.graphics();
83    this.bubbleByKey = {};
84    this.bubbleWindowStartDisplay = 1;
85    this.playerCol = TC.aisle;
86    this.playerRow = TR.cabin + 7;
87    this.drawCrew();
88    this.drawLabels();
89
90    this.keyMap = this.input.keyboard.addKeys({
91      up: Phaser.Input.Keyboard.KeyCodes.UP,
92      down: Phaser.Input.Keyboard.KeyCodes.DOWN,
93      w: Phaser.Input.Keyboard.KeyCodes.W,
94      s: Phaser.Input.Keyboard.KeyCodes.S,
95      left: Phaser.Input.Keyboard.KeyCodes.LEFT,
96      right: Phaser.Input.Keyboard.KeyCodes.RIGHT,
97      a: Phaser.Input.Keyboard.KeyCodes.A,
98      d: Phaser.Input.Keyboard.KeyCodes.D,
99      space: Phaser.Input.Keyboard.KeyCodes.SPACE,
100      c: Phaser.Input.Keyboard.KeyCodes.C,
101      esc: Phaser.Input.Keyboard.KeyCodes.ESC,
102      shift: Phaser.Input.Keyboard.KeyCodes.SHIFT,
103      one: Phaser.Input.Keyboard.KeyCodes.ONE,
104      two: Phaser.Input.Keyboard.KeyCodes.TWO,
105      three: Phaser.Input.Keyboard.KeyCodes.THREE,
106    });
107
108    this.trolley = null;
109    this.collectionWrapUpStarted = false;
110    this.winSceneScheduled = false;
111    this.serviceEntryComplete = true;
112    this.setPhase("idle");
113    this.updateHudMeta();
114    this.updateTimerHud();
115    this.updateScore(0);
116    this.updateHintBar();
117
118    this.soundAmbience = this.cache.audio.exists("cabin_ambience")
119      ? this.sound.add("cabin_ambience", { loop: true, volume: 0.25 })
120      : null;
121    this.sfxCorrect = this.cache.audio.exists("sfx_correct")
122      ? this.sound.add("sfx_correct", { volume: 0.6 })
123      : null;
124    this.sfxWrong = this.cache.audio.exists("sfx_wrong")
125      ? this.sound.add("sfx_wrong", { volume: 0.6 })
126      : null;
127    this.sfxCollect = this.cache.audio.exists("sfx_collect")
128      ? this.sound.add("sfx_collect", { volume: 0.6 })
129      : null;
130    this.turbulenceSound = this.cache.audio.exists("turbulence")
131      ? this.sound.add("turbulence", { volume: 0.55 })
132      : null;
133  }
134
135  ensureFallbackSprites() {
136    const dirs = ["south", "north", "east", "west"];
137    const makeFallback = (key, bodyColor) => {
138      if (this.textures.exists(key)) return;
139      const g = this.make.graphics({ x: 0, y: 0, add: false });
140      g.fillStyle(bodyColor, 1);
141      g.fillRect(10, 18, 28, 24);
142      g.fillStyle(C.crewSkin, 1);
143      g.fillRect(14, 6, 20, 12);
144      g.generateTexture(key, 48, 48);
145      g.destroy();
146    };
147    for (let i = 0; i < dirs.length; i++) {
148      makeFallback(`female-crew-${dirs[i]}`, 0x00ff7f);
149      makeFallback(`male-crew-${dirs[i]}`, 0x44aaff);
150    }
151    if (!this.textures.exists("passengers")) {
152      const fallbackFrames = [
153        "pax-intern-a",
154        "pax-bizlady-a",
155        "pax-tourist-a",
156        "pax-elderly-a",
157        "pax-backpacker-a",
158      ];
159      for (let i = 0; i < fallbackFrames.length; i++) {
160        const key = fallbackFrames[i];
161        if (this.textures.exists(key)) continue;
162        const g = this.make.graphics({ x: 0, y: 0, add: false });
163        g.fillStyle(0x63708a + i * 0x060606, 1);
164        g.fillRect(10, 18, 28, 24);
165        g.fillStyle(0xe0bd9a, 1);
166        g.fillRect(14, 6, 20, 12);
167        g.generateTexture(key, 48, 48);
168        g.destroy();
169      }
170    }
171  }
172
173  update() {
174    if (!this.serviceEntryComplete) return;
175    if (this.handleMovement()) {
176      this.syncPassengerBubbles();
177      if (this.phase === "collection") this.syncCollectionBubbles();
178    }
179    this.handleFacingAndSelection();
180    this.handleInteractionKeys();
181  }
182
183  handleMovement() {
184    if (this.phase === "landing") return false;
185    const k = this.keyMap;
186    const moveUp =
187      Phaser.Input.Keyboard.JustDown(k.up) ||
188      Phaser.Input.Keyboard.JustDown(k.w);
189    const moveDown =
190      Phaser.Input.Keyboard.JustDown(k.down) ||
191      Phaser.Input.Keyboard.JustDown(k.s);
192    let moved = false;
193    if (moveUp && this.playerRow > TR.galley) {
194      this.playerRow -= 1;
195      this.setCrewDirection("north");
196      moved = true;
197    }
198    if (moveDown && this.playerRow <= TR.cabin + 7) {
199      this.playerRow += 1;
200      if (this.phase === "service" || this.phase === "collection") {
201        this.setCrewDirection("north");
202      } else {
203        this.setCrewDirection("south");
204      }
205      moved = true;
206    }
207    if (moved) {
208      this.syncCrewPosition();
209      if (this.phase === "service") {
210        this.selectedServiceTarget = null;
211        this.promptState = null;
212      } else if (this.phase === "collection") {
213        this.selectedSeatIdx = null;
214        this.promptState = null;
215      }
216    }
217    return moved;
218  }
219
220  handleFacingAndSelection() {
221    const k = this.keyMap;
222    if (Phaser.Input.Keyboard.JustDown(k.a)) {
223      this.facing = "west";
224      this.setCrewDirection("west");
225      if (this.phase === "service") {
226        this.serviceSideChosen = true;
227        this.selectedServiceTarget = null;
228      } else {
229        this.selectedSeatIdx = null;
230      }
231      this.syncPassengerBubbles();
232      if (this.phase === "collection") this.syncCollectionBubbles();
233    }
234    if (Phaser.Input.Keyboard.JustDown(k.d)) {
235      this.facing = "east";
236      this.setCrewDirection("east");
237      if (this.phase === "service") {
238        this.serviceSideChosen = true;
239        this.selectedServiceTarget = null;
240      } else {
241        this.selectedSeatIdx = null;
242      }
243      this.syncPassengerBubbles();
244      if (this.phase === "collection") this.syncCollectionBubbles();
245    }
246    if (Phaser.Input.Keyboard.JustDown(k.left)) {
247      if (this.phase === "service" && !this.serviceSideChosen) return;
248      this.moveSeatSelection(-1);
249    }
250    if (Phaser.Input.Keyboard.JustDown(k.right)) {
251      if (this.phase === "service" && !this.serviceSideChosen) return;
252      this.moveSeatSelection(1);
253    }
254  }
255
256  handleInteractionKeys() {
257    const k = this.keyMap;
258    if (Phaser.Input.Keyboard.JustDown(k.esc)) {
259      this.promptState = null;
260      this.selectedSeatIdx = null;
261      this.selectedServiceTarget = null;
262      if (this.phase === "service") this.serviceSideChosen = false;
263      this.updateHintBar();
264      this.syncPassengerBubbles();
265      if (this.phase === "collection") this.syncCollectionBubbles();
266      return;
267    }
268    if (this.phase === "idle" && Phaser.Input.Keyboard.JustDown(k.space)) {
269      this.startServicePhase();
270      return;
271    }
272    if (this.phase === "service") {
273      this.handleServiceInputs();
274      return;
275    }
276    if (this.phase === "collection") {
277      this.handleCollectionInputs();
278      return;
279    }
280  }
281
282  handleServiceInputs() {
283    const k = this.keyMap;
284    const seat = this.getSelectedSeat();
285    if (!seat || !seat.occ || seat.served) return;
286
287    if (Phaser.Input.Keyboard.JustDown(k.shift)) {
288      if (seat.state === "sleeping" || seat.state === "nothanks") {
289        seat.served = true;
290        this.updateAfterServiceResolution();
291      }
292      return;
293    }
294
295    if (Phaser.Input.Keyboard.JustDown(k.space)) {
296      this.promptState = "service";
297      this.updateHintBar();
298      return;
299    }
300
301    if (this.promptState !== "service") return;
302
303    const keyToItem = { 1: "oj", 2: "water", 3: "wine" };
304    const pressed = this.getPressedServiceKey();
305    if (!pressed) return;
306    const chosen = keyToItem[pressed];
307    if (!chosen) return;
308
309    if (seat.state === "sleeping") {
310      this.updateScore(SERVICE_POINTS.sleepingWake);
311      this.promptState = null;
312      this.updateHintBar();
313      return;
314    }
315
316    if (seat.state === chosen) {
317      if (this.sfxCorrect) {
318        this.sfxCorrect.stop();
319        this.sfxCorrect.play();
320      }
321      this.updateScore(SERVICE_POINTS.drink);
322      seat.served = true;
323      seat.hasCup = true;
324      this.promptState = null;
325      this.updateAfterServiceResolution();
326    } else {
327      if (this.sfxWrong) {
328        this.sfxWrong.stop();
329        this.sfxWrong.play();
330      }
331      this.updateScore(SERVICE_POINTS.wrongItem);
332      this.promptState = null;
333      this.updateHintBar();
334    }
335  }
336
337  handleCollectionInputs() {
338    const k = this.keyMap;
339
340    if (Phaser.Input.Keyboard.JustDown(k.space)) {
341      const seat = this.getSelectedSeat();
342      if (!seat) return;
343      const isCollectable = seat.hasCup && !seat.cupCollected;
344      const isSleeping = seat.state === "sleeping" && !seat.cupCollected;
345      if (!isCollectable && !isSleeping) return;
346      this.promptState = "collection";
347      this.updateHintBar();
348      return;
349    }
350
351    if (this.promptState !== "collection") return;
352
353    const seat = this.getSelectedSeat();
354    if (!seat) return;
355
356    if (Phaser.Input.Keyboard.JustDown(k.c)) {
357      if (seat.state === "sleeping") {
358        this.updateScore(-10);
359        this.promptState = null;
360        this.updateHintBar();
361        return;
362      }
363      if (seat.hasCup && !seat.cupCollected) {
364        if (this.sfxCollect) {
365          this.sfxCollect.stop();
366          this.sfxCollect.play();
367        }
368        seat.cupCollected = true;
369        this.updateScore(SCORE_RULES.collectionGood);
370        this.promptState = null;
371        this.updateHintBar();
372        this.syncCollectionBubbles();
373        this.tryCompleteCollectionEarly();
374      }
375      return;
376    }
377
378    if (Phaser.Input.Keyboard.JustDown(k.shift)) {
379      seat.cupCollected = true;
380      this.promptState = null;
381      this.updateHintBar();
382      this.syncCollectionBubbles();
383      this.tryCompleteCollectionEarly();
384    }
385  }
386
387  getPressedServiceKey() {
388    const k = this.keyMap;
389    if (Phaser.Input.Keyboard.JustDown(k.one)) return 1;
390    if (Phaser.Input.Keyboard.JustDown(k.two)) return 2;
391    if (Phaser.Input.Keyboard.JustDown(k.three)) return 3;
392    return null;
393  }
394
395  getCurrentRowNum() {
396    return this.playerRow - TR.cabin + 1;
397  }
398
399  seatColForSeatIndex(seatIdx) {
400    return [...SEAT_COLS_LEFT, ...SEAT_COLS_RIGHT][seatIdx];
401  }
402
403  getSelectableSeatIndices() {
404    if (this.phase === "service") return this.getSelectableServiceTargets();
405    const rowNum = this.getCurrentRowNum();
406    if (rowNum < 1 || rowNum > CABIN_ROWS) return [];
407    if (this.phase === "collection") {
408      const arr = this.facing === "east" ? [3, 4, 5] : [0, 1, 2];
409      const seen = new Set();
410      const result = [];
411      for (let i = 0; i < arr.length; i++) {
412        const idx = arr[i];
413        if (seen.has(idx)) continue;
414        const seat = this.paxMap[rowNum][idx];
415        if (!seat.occ) continue;
416        if (
417          (seat.hasCup && !seat.cupCollected) ||
418          (seat.state === "sleeping" && !seat.cupCollected)
419        ) {
420          result.push(idx);
421          seen.add(idx);
422        }
423      }
424      return result;
425    }
426    return [];
427  }
428
429  moveSeatSelection(delta) {
430    const seats = this.getSelectableSeatIndices();
431    if (!seats.length) {
432      if (this.phase === "service") this.selectedServiceTarget = null;
433      else {
434        this.selectedSeatIdx = null;
435        if (this.phase === "collection") this.syncCollectionBubbles();
436      }
437      return;
438    }
439    const current =
440      this.phase === "service"
441        ? this.selectedServiceTarget
442        : this.selectedSeatIdx;
443    let i = seats.indexOf(current);
444    if (i < 0) i = 0;
445    i = Phaser.Math.Wrap(i + delta, 0, seats.length);
446    if (this.phase === "service") {
447      this.selectedServiceTarget = seats[i];
448      this.syncPassengerBubbles();
449    } else {
450      this.selectedSeatIdx = seats[i];
451      if (this.phase === "collection") this.syncCollectionBubbles();
452    }
453  }
454
455  getSelectedSeat() {
456    if (this.phase === "service") {
457      if (!this.selectedServiceTarget) return null;
458      const [rowText, seatText] = this.selectedServiceTarget.split("-");
459      const rowNum = Number(rowText);
460      const seatIdx = Number(seatText);
461      if (rowNum < 1 || rowNum > CABIN_ROWS || seatIdx < 0 || seatIdx > 5)
462        return null;
463      return this.paxMap[rowNum][seatIdx];
464    }
465    const rowNum = this.getCurrentRowNum();
466    if (rowNum < 1 || rowNum > CABIN_ROWS || this.selectedSeatIdx == null)
467      return null;
468    return this.paxMap[rowNum][this.selectedSeatIdx];
469  }
470
471  updateAfterServiceResolution() {
472    this.checkBubbleWindowAdvance();
473    const liveTargets = this.getSelectableServiceTargets();
474    if (!liveTargets.includes(this.selectedServiceTarget))
475      this.selectedServiceTarget = null;
476    this.syncPassengerBubbles();
477    this.updateHintBar();
478
479    if (this.allServiceResolved() && this.phase === "service") {
480      const bonus = Math.max(0, this.remainingSec - 100);
481      if (bonus > 0) this.updateScore(bonus);
482      this.remainingSec = 100;
483      this.updateTimerHud();
484      this.showServicePopup();
485      this.enterCollectionPhase();
486    }
487  }
488
489  getSelectableServiceTargets() {
490    const rowNum = this.getCurrentRowNum();
491    if (rowNum < 1 || rowNum > CABIN_ROWS) return [];
492    const side = this.facing === "west" ? [0, 1, 2] : [3, 4, 5];
493    const targets = [];
494    for (let s = 0; s < side.length; s++) {
495      const seatIdx = side[s];
496      const seat = this.paxMap[rowNum][seatIdx];
497      if (seat.occ && !seat.served && seat.state)
498        targets.push(`${rowNum}-${seatIdx}`);
499    }
500    return targets;
501  }
502
503  checkBubbleWindowAdvance() {
504    const d = this.bubbleWindowStartDisplay;
505    const rowNum = 9 - d;
506    if (rowNum < 1 || rowNum > CABIN_ROWS) return;
507    const row = this.paxMap[rowNum];
508    const done = row.every((s) => !s.occ || s.served);
509    if (done) this.bubbleWindowStartDisplay = Math.min(7, d + 1);
510  }
511
512  setPhase(phase) {
513    this.phase = phase;
514    const labels = {
515      idle: "BOARDING",
516      service: "SERVICE",
517      collection: "COLLECTION",
518      landing: "LANDING",
519    };
520    const el = document.getElementById("hud-phase");
521    if (el) el.textContent = `PHASE: ${labels[phase] || phase.toUpperCase()}`;
522    if (phase === "service") this.setAisleTint(true);
523    if (phase === "landing") {
524      this.setAisleTint(false);
525      if (this.soundAmbience && this.soundAmbience.isPlaying) {
526        this.soundAmbience.stop();
527      }
528    }
529    if (phase !== "service") {
530      this.fadeOutAllPassengerBubbles();
531      this.serviceSideChosen = false;
532      this.selectedServiceTarget = null;
533    }
534    this.updateHintBar();
535  }
536
537  stopGamestartMusic() {
538    const s = this.registry.get("gamestartSound");
539    if (s) {
540      s.stop();
541      s.destroy();
542      this.registry.remove("gamestartSound");
543    }
544  }
545
546  startServicePhase() {
547    this.stopGamestartMusic();
548    if (!this.trolley) {
549      this.trolley = this.add
550        .image(tx(TC.aisle) + TILE / 2, ty(TR.galley) + TILE / 2 + 4, "trolley")
551        .setDepth(10);
552    }
553    this.setPhase("service");
554    if (this.soundAmbience && !this.soundAmbience.isPlaying) {
555      this.soundAmbience.play();
556    }
557    this.startTimer();
558    this.serviceEntryComplete = false;
559    this.setCrewDirection("north");
560    this.tweens.add({
561      targets: this.crewSprite,
562      y: ty(TR.galley + 1) + TILE / 2,
563      duration: 240,
564      onComplete: () => {
565        this.playerRow = TR.galley + 1;
566        this.syncCrewPosition();
567        this.serviceEntryComplete = true;
568        this.bubbleWindowStartDisplay = 1;
569        this.serviceSideChosen = false;
570        this.selectedServiceTarget = null;
571        this.syncPassengerBubbles();
572      },
573    });
574    this.scheduleTurbulence();
575  }
576
577  startTimer() {
578    if (this.timerEvent) return;
579    this.timerEvent = this.time.addEvent({
580      delay: 1000,
581      loop: true,
582      callback: () => {
583        if (this.remainingSec <= 0) return;
584        this.remainingSec -= 1;
585        this.updateTimerHud();
586        this.checkPhaseCheckpoints();
587      },
588    });
589  }
590
591  checkPhaseCheckpoints() {
592    if (this.remainingSec <= 60) {
593      const timerEl = document.getElementById("hud-timer");
594      if (timerEl) timerEl.classList.add("is-warning");
595    }
596    if (this.remainingSec === 100 && this.phase === "service") {
597      this.showServicePopup();
598      this.enterCollectionPhase();
599    }
600    if (
601      this.remainingSec === 30 &&
602      this.phase === "collection" &&
603      !this.collectionWrapUpStarted
604    ) {
605      this.finishCollectionPhase();
606    }
607  }
608
609  showServicePopup() {
610    const allServed = this.allServiceResolved();
611    const txt = allServed
612      ? "Great job! Time to collect back what we have served out."
613      : "Please buck up on your service speed for the next flight.";
614    const popup = this.add
615      .text(CW / 2, CH / 2, txt, {
616        fontFamily: '"Press Start 2P"',
617        fontSize: "9px",
618        color: "#1b2a4a",
619        backgroundColor: "#f5f0e8",
620        padding: { x: 12, y: 10 },
621        align: "center",
622        wordWrap: { width: 600 },
623      })
624      .setOrigin(0.5)
625      .setDepth(300);
626    this.serviceEntryComplete = false;
627    this.time.delayedCall(2800, () => {
628      popup.destroy();
629      this.serviceEntryComplete = true;
630    });
631  }
632
633  enterCollectionPhase() {
634    this.collectionWrapUpStarted = false;
635    this.selectedSeatIdx = null;
636    this.promptState = null;
637    if (this.trolley)
638      this.trolley.setPosition(
639        tx(TC.aisle) + TILE / 2,
640        ty(TR.galley) + TILE / 2 + 4,
641      );
642    this.setPhase("collection");
643    this.syncCollectionBubbles();
644  }
645
646  allCollectionResolved() {
647    for (let r = 1; r <= CABIN_ROWS; r++) {
648      for (let s = 0; s < 6; s++) {
649        const seat = this.paxMap[r][s];
650        if (!seat.occ) continue;
651        if (seat.hasCup && !seat.cupCollected) return false;
652        if (seat.state === "sleeping" && !seat.cupCollected) return false;
653      }
654    }
655    return true;
656  }
657
658  tryCompleteCollectionEarly() {
659    if (this.phase !== "collection" || this.collectionWrapUpStarted) return;
660    if (this.allCollectionResolved()) this.finishCollectionPhase();
661  }
662
663  finishCollectionPhase() {
664    if (this.phase !== "collection" || this.collectionWrapUpStarted) return;
665    this.collectionWrapUpStarted = true;
666
667    const allCupsDone = this.allCollectionResolved();
668
669    const bonus = Math.max(0, this.remainingSec - 30);
670    if (bonus > 0) this.updateScore(bonus);
671
672    for (let r = 1; r <= CABIN_ROWS; r++) {
673      for (let s = 0; s < 6; s++) {
674        const seat = this.paxMap[r][s];
675        if (seat.hasCup && !seat.cupCollected && seat.state !== "sleeping") {
676          this.updateScore(SCORE_RULES.collectionMiss);
677        }
678      }
679    }
680
681    const txt = allCupsDone
682      ? "Great! All cups collected. Time to prepare for landing."
683      : "Need to be quicker next time. Time to prepare for landing.";
684    const popup = this.add
685      .text(CW / 2, CH / 2, txt, {
686        fontFamily: '"Press Start 2P"',
687        fontSize: "9px",
688        color: "#1b2a4a",
689        backgroundColor: "#f5f0e8",
690        padding: { x: 12, y: 10 },
691        align: "center",
692        wordWrap: { width: 600 },
693      })
694      .setOrigin(0.5)
695      .setDepth(300);
696    this.serviceEntryComplete = false;
697    this.time.delayedCall(2800, () => {
698      popup.destroy();
699      this.startLandingSequence();
700    });
701  }
702
703  goToWinSceneAfterLanding(delayMs = 0) {
704    if (this.winSceneScheduled) return;
705    this.winSceneScheduled = true;
706    this.time.delayedCall(delayMs, () => {
707      if (this.scene.isActive("CabinScene")) {
708        this.scene.start("WinScene", { score: this.score });
709      }
710    });
711  }
712
713  runLandingPatrol() {
714    const moveMs = 380;
715    const lookMs = 420;
716    const returnMoveMs = 520;
717    const aisleX = tx(TC.aisle) + TILE / 2;
718    this.crewSprite.setAlpha(1);
719    this.playerRow = TR.cabin;
720    this.crewSprite.setPosition(aisleX, ty(this.playerRow) + TILE);
721    this.setCrewDirection("north");
722
723    const fadeCrewOut = () => {
724      this.tweens.add({
725        targets: this.crewSprite,
726        alpha: 0,
727        duration: 1400,
728        ease: "Sine.easeIn",
729      });
730    };
731
732    const runReturn = (rowNum) => {
733      if (rowNum < 1) {
734        this.setCrewDirection("north");
735        this.playerRow = TR.galley;
736        const galleyY = ty(this.playerRow) + TILE;
737        this.tweens.add({
738          targets: this.crewSprite,
739          y: galleyY,
740          duration: returnMoveMs,
741          ease: "Sine.easeInOut",
742          onComplete: () => fadeCrewOut(),
743        });
744        return;
745      }
746      this.setCrewDirection("north");
747      this.playerRow = TR.cabin + rowNum - 1;
748      const targetY = ty(this.playerRow) + TILE;
749      this.tweens.add({
750        targets: this.crewSprite,
751        y: targetY,
752        duration: returnMoveMs,
753        ease: "Sine.easeInOut",
754        onComplete: () => {
755          runReturn(rowNum - 1);
756        },
757      });
758    };
759
760    const runRow = (rowNum) => {
761      if (rowNum > CABIN_ROWS) {
762        runReturn(CABIN_ROWS - 1);
763        return;
764      }
765      this.playerRow = TR.cabin + rowNum - 1;
766      const targetY = ty(this.playerRow) + TILE;
767      this.tweens.add({
768        targets: this.crewSprite,
769        y: targetY,
770        duration: moveMs,
771        ease: "Sine.easeInOut",
772        onComplete: () => {
773          this.setCrewDirection("west");
774          this.time.delayedCall(lookMs, () => {
775            this.setCrewDirection("east");
776            this.time.delayedCall(lookMs, () => {
777              runRow(rowNum + 1);
778            });
779          });
780        },
781      });
782    };
783
784    runRow(1);
785  }
786
787  startLandingSequence() {
788    this.winSceneScheduled = false;
789    this.serviceEntryComplete = false;
790    this.setPhase("landing");
791    if (this.trolley) {
792      this.trolley.setVisible(false);
793    }
794    this.cameras.main.shake(1000, 0.004);
795
796    this.runLandingPatrol();
797
798    const playSeatbeltChimeThenWin = () => {
799      if (!this.cache.audio.exists("sfx_seatbelt_chime")) {
800        this.goToWinSceneAfterLanding(1000);
801        return;
802      }
803      const chime = this.sound.add("sfx_seatbelt_chime", { volume: 0.6 });
804      chime.once("complete", () => {
805        // Win scene should begin 1s after seatbelt chime ends.
806        this.goToWinSceneAfterLanding(1000);
807      });
808      chime.play();
809    };
810
811    if (this.cache.audio.exists("landing_announcement")) {
812      const announcement = this.sound.add("landing_announcement", {
813        volume: 0.6,
814      });
815      announcement.play();
816      announcement.once("complete", () => {
817        playSeatbeltChimeThenWin();
818      });
819    } else {
820      playSeatbeltChimeThenWin();
821    }
822
823    this.time.delayedCall(25000, () => {
824      if (this.scene.isActive("CabinScene")) {
825        this.goToWinSceneAfterLanding(0);
826      }
827    });
828  }
829
830  scheduleTurbulence() {
831    const t = this.route.turbulence && this.route.turbulence[0];
832    if (!t) return;
833    this.time.delayedCall(t.elapsedSec * 1000, () => {
834      if (this.phase !== "service") return;
835      this.cameras.main.shake(t.durationSec * 1000, 0.005);
836      if (this.turbulenceSound) {
837        this.turbulenceSound.stop();
838        this.turbulenceSound.play();
839        this.time.delayedCall(t.durationSec * 1000, () => {
840          if (this.turbulenceSound && this.turbulenceSound.isPlaying) {
841            this.turbulenceSound.stop();
842          }
843        });
844      }
845      const txt = this.add
846        .text(CW / 2, CH / 2 - 40, "WE HIT TURBULENCE\n(not the song)", {
847          fontFamily: '"Press Start 2P"',
848          fontSize: "11px",
849          color: "#ffff44",
850          backgroundColor: "#1b2a4a",
851          padding: { x: 14, y: 10 },
852          align: "center",
853        })
854        .setOrigin(0.5)
855        .setDepth(200);
856      const flashTween = this.tweens.add({
857        targets: txt,
858        alpha: { from: 1, to: 0 },
859        duration: 400,
860        yoyo: true,
861        repeat: -1,
862      });
863      this.time.delayedCall(t.durationSec * 1000, () => {
864        flashTween.stop();
865        txt.destroy();
866      });
867    });
868  }
869
870  allServiceResolved() {
871    for (let r = 1; r <= CABIN_ROWS; r++) {
872      for (let s = 0; s < 6; s++) {
873        const seat = this.paxMap[r][s];
874        if (seat.occ && !seat.served) return false;
875      }
876    }
877    return true;
878  }
879
880  updateHudMeta() {
881    const routeEl = document.getElementById("hud-route");
882    if (routeEl) routeEl.textContent = this.route.label;
883    const armed = document.getElementById("hud-armed");
884    if (armed) {
885      armed.textContent = "DOORS TO ARMED";
886      armed.style.color = "#ff4444";
887    }
888  }
889
890  updateTimerHud() {
891    const mm = String(Math.floor(this.remainingSec / 60)).padStart(2, "0");
892    const ss = String(this.remainingSec % 60).padStart(2, "0");
893    const el = document.getElementById("hud-timer");
894    if (el) el.textContent = `${mm}:${ss}`;
895  }
896
897  updateScore(delta) {
898    this.score += delta;
899    const el = document.getElementById("hud-score");
900    if (el) el.textContent = `SCORE: ${this.score}`;
901  }
902
903  updateHintBar() {
904    const el = document.getElementById("hint-bar");
905    if (!el) return;
906    if (this.phase === "idle") {
907      el.textContent = "SPACEBAR — Begin service";
908    } else if (this.phase === "service" && this.promptState !== "service") {
909      el.textContent =
910        "A / D — Face seats | ← → — Select | SPACEBAR — Serve | SHIFT — Skip";
911    } else if (this.phase === "service" && this.promptState === "service") {
912      el.textContent = "1 OJ  |  2 WATER  |  3 WINE  |  ESC CANCEL";
913    } else if (
914      this.phase === "collection" &&
915      this.promptState === "collection"
916    ) {
917      el.textContent = "C — Collect  |  SHIFT — Skip  |  ESC — Cancel";
918    } else if (this.phase === "collection") {
919      el.textContent =
920        "A / D — Face seats | ← → — Select | SPACEBAR — Interact | C — Collect | SHIFT — Skip";
921    } else if (this.phase === "landing") {
922      el.textContent = "Cabin checked and ready for landing...";
923    }
924  }
925
926  syncCrewPosition() {
927    this.crewSprite.setPosition(
928      tx(TC.aisle) + TILE / 2,
929      ty(this.playerRow) + TILE,
930    );
931    if (
932      (this.phase === "service" || this.phase === "collection") &&
933      this.trolley
934    ) {
935      this.trolley.setPosition(
936        tx(TC.aisle) + TILE / 2,
937        ty(this.playerRow - 1) + TILE / 2 + 4,
938      );
939    }
940  }
941
942  setAisleTint(active) {
943    this.aisleTint.clear();
944    if (!active) return;
945    this.aisleTint.fillStyle(0xd4a96a, 0.35);
946    for (let r = 0; r < CABIN_ROWS; r++) {
947      this.aisleTint.fillRect(tx(TC.aisle), ty(TR.cabin + r), TILE, TILE);
948    }
949  }
950
951  fadeOutAllPassengerBubbles() {
952    const keys = Object.keys(this.bubbleByKey);
953    for (let i = 0; i < keys.length; i++) {
954      const spr = this.bubbleByKey[keys[i]];
955      if (spr) spr.destroy();
956      delete this.bubbleByKey[keys[i]];
957    }
958  }
959
960  syncPassengerBubbles() {
961    if (this.phase !== "service") return;
962    const bubbleSize = Math.round(TILE * 0.82);
963    const bubbleSizeActive = Math.round(TILE * 0.9);
964    const desired = new Set();
965    const d0 = this.bubbleWindowStartDisplay;
966    const rows = [9 - d0, 9 - (d0 + 1)].filter(
967      (r) => r >= 1 && r <= CABIN_ROWS,
968    );
969    for (let i = 0; i < rows.length; i++) {
970      const rowNum = rows[i];
971      for (let seatIdx = 0; seatIdx < 6; seatIdx++) {
972        const seat = this.paxMap[rowNum][seatIdx];
973        if (seat.occ && !seat.served && seat.state)
974          desired.add(`${rowNum}-${seatIdx}`);
975      }
976    }
977    const keys = Object.keys(this.bubbleByKey);
978    for (let i = 0; i < keys.length; i++) {
979      if (!desired.has(keys[i])) {
980        this.bubbleByKey[keys[i]].destroy();
981        delete this.bubbleByKey[keys[i]];
982      }
983    }
984    if (
985      this.selectedServiceTarget &&
986      !desired.has(this.selectedServiceTarget)
987    ) {
988      this.selectedServiceTarget = null;
989    }
990    desired.forEach((key) => {
991      if (this.bubbleByKey[key]) return;
992      const [rowText, seatText] = key.split("-");
993      const rowNum = Number(rowText);
994      const seatIdx = Number(seatText);
995      const seat = this.paxMap[rowNum][seatIdx];
996      const col = this.seatColForSeatIndex(seatIdx);
997      const tex = `bubble_${seat.state.split("+")[0]}`;
998      const img = this.add
999        .image(tx(col) + TILE / 2, ty(TR.cabin + rowNum - 1) + 3, tex)
1000        .setOrigin(0.5, 1)
1001        .setDisplaySize(bubbleSize, bubbleSize)
1002        .setDepth(100);
1003      this.bubbleByKey[key] = img;
1004    });
1005    const allKeys = Object.keys(this.bubbleByKey);
1006    for (let i = 0; i < allKeys.length; i++) {
1007      const key = allKeys[i];
1008      const bubble = this.bubbleByKey[key];
1009      if (!bubble) continue;
1010      const active = key === this.selectedServiceTarget;
1011      bubble.setDisplaySize(
1012        active ? bubbleSizeActive : bubbleSize,
1013        active ? bubbleSizeActive : bubbleSize,
1014      );
1015      bubble.setTint(active ? 0xd6ffad : 0xffffff);
1016    }
1017  }
1018
1019  syncCollectionBubbles() {
1020    if (this.phase !== "collection") return;
1021    const bubbleSize = Math.round(TILE * 0.82);
1022    const bubbleSizeActive = Math.round(TILE * 0.9);
1023
1024    const desired = new Set();
1025    for (let rowNum = 1; rowNum <= CABIN_ROWS; rowNum++) {
1026      for (let seatIdx = 0; seatIdx < 6; seatIdx++) {
1027        const seat = this.paxMap[rowNum][seatIdx];
1028        if (!seat.occ) continue;
1029        if (seat.hasCup && !seat.cupCollected)
1030          desired.add(`${rowNum}-${seatIdx}`);
1031        else if (seat.state === "sleeping" && !seat.cupCollected)
1032          desired.add(`${rowNum}-${seatIdx}`);
1033      }
1034    }
1035
1036    const keys = Object.keys(this.bubbleByKey);
1037    for (let i = 0; i < keys.length; i++) {
1038      if (!desired.has(keys[i])) {
1039        this.bubbleByKey[keys[i]].destroy();
1040        delete this.bubbleByKey[keys[i]];
1041      }
1042    }
1043
1044    desired.forEach((key) => {
1045      if (this.bubbleByKey[key]) return;
1046      const [rowText, seatText] = key.split("-");
1047      const rowNum = Number(rowText);
1048      const seatIdx = Number(seatText);
1049      const seat = this.paxMap[rowNum][seatIdx];
1050      const col = this.seatColForSeatIndex(seatIdx);
1051      const tex =
1052        seat.state === "sleeping" ? "bubble_sleeping" : "bubble_emptycup";
1053      const img = this.add
1054        .image(tx(col) + TILE / 2, ty(TR.cabin + rowNum - 1) + 3, tex)
1055        .setOrigin(0.5, 1)
1056        .setDisplaySize(bubbleSize, bubbleSize)
1057        .setDepth(100);
1058      this.bubbleByKey[key] = img;
1059    });
1060
1061    const activeKey =
1062      this.selectedSeatIdx !== null
1063        ? `${this.getCurrentRowNum()}-${this.selectedSeatIdx}`
1064        : null;
1065    const allKeys = Object.keys(this.bubbleByKey);
1066    for (let i = 0; i < allKeys.length; i++) {
1067      const key = allKeys[i];
1068      const bubble = this.bubbleByKey[key];
1069      if (!bubble) continue;
1070      const isActive = key === activeKey;
1071      bubble.setDisplaySize(
1072        isActive ? bubbleSizeActive : bubbleSize,
1073        isActive ? bubbleSizeActive : bubbleSize,
1074      );
1075      bubble.setTint(isActive ? 0xd6ffad : 0xffffff);
1076    }
1077  }
1078
1079  drawAisle() {
1080    for (let r = 0; r < CABIN_ROWS; r++) {
1081      this.add.image(
1082        tx(TC.aisle) + TILE / 2,
1083        ty(TR.cabin + r) + TILE / 2,
1084        "aisle_tile",
1085      );
1086    }
1087  }
1088
1089  drawFuselage() {
1090    for (let r = 0; r < GRID_ROWS; r++) {
1091      this.add.image(tx(TC.fuseLeft) + TILE / 2, ty(r) + TILE / 2, "fuse_wall");
1092      this.add.image(
1093        tx(TC.fuseRight) + TILE / 2,
1094        ty(r) + TILE / 2,
1095        "fuse_wall",
1096      );
1097    }
1098    for (let c = 0; c < GRID_COLS; c++) {
1099      this.add.image(tx(c) + TILE / 2, ty(TR.fuseTop) + TILE / 2, "fuse_wall");
1100      this.add
1101        .image(tx(c) + TILE / 2, ty(TR.fuseBot) + TILE / 2, "fuse_wall")
1102        .setFlipY(true);
1103    }
1104    for (let r = 0; r < CABIN_ROWS; r++) {
1105      const tileRow = TR.cabin + r;
1106      const py = ty(tileRow) + TILE / 2;
1107      this.add.image(tx(TC.fuseLeft) + TILE / 2, py, "porthole");
1108      this.add.image(tx(TC.fuseRight) + TILE / 2, py, "porthole");
1109    }
1110  }
1111
1112  drawCabin() {
1113    const seatBg = this.add.graphics();
1114    seatBg.fillStyle(0x03165c, 1);
1115    const seatCols = [...SEAT_COLS_LEFT, ...SEAT_COLS_RIGHT];
1116    for (let r = 0; r < CABIN_ROWS; r++) {
1117      const tileRow = TR.cabin + r;
1118      const rowNum = r + 1;
1119      seatCols.forEach((col, seatIdx) => {
1120        const cx = tx(col) + TILE / 2;
1121        const cy = ty(tileRow) + TILE / 2;
1122        const seat = this.paxMap[rowNum][seatIdx];
1123        seatBg.fillRect(tx(col) + 2, ty(tileRow) + 2, TILE - 4, TILE - 4);
1124        if (seat.occ) {
1125          const usesAtlas =
1126            this.textures.exists("passengers") &&
1127            this.textures.get("passengers").has(seat.spriteFrame);
1128          if (usesAtlas) {
1129            this.add
1130              .image(cx, cy, "passengers", seat.spriteFrame)
1131              .setDisplaySize(TILE - 20, TILE - 20);
1132          } else {
1133            this.add
1134              .image(cx, cy, "pax-intern-a")
1135              .setDisplaySize(TILE - 20, TILE - 20);
1136          }
1137        }
1138      });
1139    }
1140  }
1141
1142  drawCockpit() {
1143    for (let c = TC.seatA; c <= TC.seatF; c++) {
1144      this.add.image(
1145        tx(c) + TILE / 2,
1146        ty(TR.cockpit) + TILE / 2,
1147        "cockpit_fill",
1148      );
1149    }
1150    const g = this.add.graphics();
1151    const x0 = tx(TC.seatA);
1152    const y0 = ty(TR.cockpit);
1153    const w = (TC.seatF - TC.seatA + 1) * TILE;
1154    const h = TILE;
1155    g.lineStyle(2, 0x2a4068, 1);
1156    g.strokeRect(x0 + 2, y0 + 2, w - 4, h - 4);
1157    g.fillStyle(0x1b2a4a);
1158    g.fillRect(x0 + 5, y0 + 5, w - 10, h - 10);
1159    g.lineStyle(1.5, 0x0a1020);
1160    g.strokeRect(x0 + 7, y0 + 7, w - 14, h - 14);
1161    g.lineStyle(1.5, 0x0a1020);
1162    g.beginPath();
1163    g.moveTo(x0 + w / 2, y0 + 9);
1164    g.lineTo(x0 + w / 2, y0 + h - 9);
1165    g.strokePath();
1166    g.fillStyle(0x3a5080);
1167    g.fillRect(x0 + w - 15, y0 + h / 2 - 5, 6, 10);
1168    const doorCx = x0 + w / 2;
1169    const doorCy = y0 + h / 2;
1170    this.add.image(doorCx - 26, doorCy, "porthole").setScale(1);
1171    this.add.image(doorCx + 26, doorCy, "porthole").setScale(1);
1172    this.add
1173      .text(x0 + w / 2, y0 + h / 2 + 1, "COCKPIT", {
1174        fontFamily: '"Press Start 2P"',
1175        fontSize: "7px",
1176        color: "#c8e6ff",
1177        stroke: "#0a1020",
1178        strokeThickness: 3,
1179      })
1180      .setOrigin(0.5, 0.5);
1181  }
1182
1183  drawGalley() {
1184    for (let c = TC.seatA; c <= TC.seatF; c++) {
1185      this.add.image(tx(c) + TILE / 2, ty(TR.galley) + TILE / 2, "galley_fill");
1186    }
1187    const g = this.add.graphics();
1188    const x0 = tx(TC.seatA);
1189    const y0 = ty(TR.galley);
1190    const w = (TC.seatF - TC.seatA + 1) * TILE;
1191    g.fillStyle(0xb8b8b8);
1192    g.fillRect(x0 + 2, y0 + 2, w - 4, 11);
1193    g.lineStyle(1, 0x888888, 1);
1194    g.strokeRect(x0 + 2, y0 + 2, w - 4, 11);
1195    g.fillStyle(0x999999);
1196    g.fillRect(x0 + 4, y0 + 16, w - 8, TILE - 20);
1197    g.fillStyle(0x888888);
1198    g.fillRect(x0 + 6, y0 + 19, w - 12, 4);
1199    const lightsY = y0 + 24;
1200    const step = Math.floor((w - 20) / 6);
1201    for (let i = 0; i < 6; i++) {
1202      const lx = x0 + 10 + i * step;
1203      g.fillStyle(i % 2 === 0 ? 0x00cc44 : 0xffaa00);
1204      g.fillRect(lx, lightsY, 4, 4);
1205    }
1206    this.add
1207      .text(x0 + w / 2, y0 + 34, "GALLEY", {
1208        fontFamily: '"Press Start 2P"',
1209        fontSize: "7px",
1210        color: "#1b2a4a",
1211        stroke: "#e8e8e8",
1212        strokeThickness: 2,
1213      })
1214      .setOrigin(0.5, 0.5);
1215  }
1216
1217  drawCrew() {
1218    const cx = tx(this.playerCol) + TILE / 2;
1219    const cy = ty(this.playerRow) + TILE;
1220    this.crewGender = window.selectedCrew === "male" ? "male" : "female";
1221    const prefix = this.crewGender === "male" ? "male-crew-" : "female-crew-";
1222    this.crewTexturePrefix = prefix;
1223    this.crewDir = "south";
1224    this.crewSprite = this.add
1225      .image(cx, cy, `${this.crewTexturePrefix}south`)
1226      .setOrigin(0.5, 1)
1227      .setDisplaySize(TILE, TILE * 1.5)
1228      .setDepth(20);
1229  }
1230
1231  setCrewDirection(dir) {
1232    this.crewDir = dir;
1233    this.crewSprite.setTexture(`${this.crewTexturePrefix}${dir}`);
1234  }
1235
1236  drawLabels() {
1237    const labelStyle = {
1238      fontFamily: '"Press Start 2P"',
1239      fontSize: "5px",
1240      color: "#888888",
1241    };
1242    const rowLabelX = tx(TC.fuseLeft) - 10;
1243    for (let r = 0; r < CABIN_ROWS; r++) {
1244      const tileRow = TR.cabin + r;
1245      const rowLabel = String(CABIN_ROWS - r);
1246      this.add
1247        .text(rowLabelX, ty(tileRow) + TILE / 2, rowLabel, labelStyle)
1248        .setOrigin(0.5);
1249    }
1250    const seatLetters = ["F", "E", "D", "C", "B", "A"];
1251    const seatCols = [...SEAT_COLS_LEFT, ...SEAT_COLS_RIGHT];
1252    const seatLabelY = ty(TR.cabin) - 12;
1253    const seatLetterStyle = {
1254      fontFamily: '"Press Start 2P"',
1255      fontSize: "8px",
1256      color: "#ffffff",
1257      stroke: "#000000",
1258      strokeThickness: 2,
1259    };
1260    seatCols.forEach((col, i) => {
1261      this.add
1262        .text(tx(col) + TILE / 2, seatLabelY, seatLetters[i], seatLetterStyle)
1263        .setOrigin(0.5);
1264    });
1265  }
1266}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.