PageSourceSearch

https://tranas.se/wp-content/plugins/tranas-plugins/taniga-tranan/assets/js/taniga-tranan.js?ver=1784721634

js tranas.se collected 2026-10-02 11:35:52 UTC 78,695 bytes, 2,249 lines download raw bytes

1/**
2 * Taniga Tranan – HTML5 canvas-spel.
3 *
4 * Designprinciper:
5 *  - Fixed-step physics (deterministisk fysik) med interpolerad rendering.
6 *  - HiDPI / Retina-skarp via devicePixelRatio.
7 *  - Logiskt koordinatsystem (480x720) som skalas via CSS – inga magiska siffror per device.
8 *  - Pausar automatiskt när fliken inte är synlig (Page Visibility API).
9 *  - Inga externa beroenden, ingen jQuery – ren vanilla JS.
10 */
11(function () {
12    'use strict';
13
14    var CFG = (typeof window !== 'undefined' && window.TanigaTrananConfig) || {};
15    var I18N = CFG.i18n || {};
16
17    var WORLD = {
18        width: 480,
19        height: 720,
20        gravity: 1800,         // px / s^2
21        flapVelocity: -520,    // px / s (negativ = uppåt)
22        maxFallSpeed: 900,
23        scrollSpeed: 180,      // px / s
24        groundHeight: 90,
25        ceilingPadding: -40,   // tranan får gå lite ovanför top, men allt över detta = död
26        pipeGap: 200,          // hålet mellan rör
27        pipeWidth: 78,
28        pipeInterval: 1.55,    // sekunder mellan nya rör
29        pipeMinTop: 80,        // min y för rör-toppens nederkant
30        pipeMaxTop: 380,       // max y för rör-toppens nederkant
31        difficultyRamp: 0.985, // pipeInterval *= ramp varje rör (gradvis svårare)
32        minPipeInterval: 1.05,
33    };
34
35    var STORAGE_KEY = 'tanigaTrananBest';
36
37    /* ---------------------------------------------------------------------- *
38     *  Util
39     * ---------------------------------------------------------------------- */
40
41    function clamp(v, min, max) {
42        return v < min ? min : v > max ? max : v;
43    }
44
45    function rand(min, max) {
46        return min + Math.random() * (max - min);
47    }
48
49    function loadBest() {
50        try {
51            var v = parseInt(window.localStorage.getItem(STORAGE_KEY), 10);
52            return isFinite(v) && v > 0 ? v : 0;
53        } catch (e) {
54            return 0;
55        }
56    }
57
58    function saveBest(score) {
59        try {
60            var prev = loadBest();
61            if (score > prev) {
62                window.localStorage.setItem(STORAGE_KEY, String(score));
63            }
64        } catch (e) {
65            /* ignore */
66        }
67    }
68
69    function escapeHtml(s) {
70        return String(s).replace(/[&<>"']/g, function (c) {
71            return ({
72                '&': '&amp;',
73                '<': '&lt;',
74                '>': '&gt;',
75                '"': '&quot;',
76                "'": '&#39;',
77            })[c];
78        });
79    }
80
81    /* ---------------------------------------------------------------------- *
82     *  Ljud (Web Audio API – effekter + sporadiskt fågelkvitter)
83     * ---------------------------------------------------------------------- */
84
85    var SOUND_STORAGE_KEY = 'tanigaTrananSound';
86
87    function SoundManager() {
88        this.ctx = null;
89        this.muted = false;
90        this.unlocked = false;
91        this.ambienceActive = false;
92        this.ambienceWanted = false;
93        this.ambienceGain = null;
94        this._ambienceTimer = null;
95        try {
96            if (window.localStorage.getItem(SOUND_STORAGE_KEY) === 'muted') {
97                this.muted = true;
98            }
99        } catch (e) {
100            /* ignore */
101        }
102    }
103
104    SoundManager.prototype.ensureContext = function () {
105        if (this.ctx) return this.ctx;
106        var Ctx = window.AudioContext || window.webkitAudioContext;
107        if (!Ctx) return null;
108        this.ctx = new Ctx();
109        return this.ctx;
110    };
111
112    SoundManager.prototype.unlock = function () {
113        if (this.unlocked) return;
114        var ctx = this.ensureContext();
115        if (!ctx) return;
116        if (ctx.state === 'suspended') {
117            ctx.resume();
118        }
119        try {
120            var buffer = ctx.createBuffer(1, 1, 22050);
121            var source = ctx.createBufferSource();
122            source.buffer = buffer;
123            source.connect(ctx.destination);
124            source.start(0);
125        } catch (e) {
126            /* ignore */
127        }
128        this.unlocked = true;
129    };
130
131    SoundManager.prototype.setMuted = function (muted) {
132        this.muted = !!muted;
133        try {
134            window.localStorage.setItem(
135                SOUND_STORAGE_KEY,
136                this.muted ? 'muted' : 'on'
137            );
138        } catch (e) {
139            /* ignore */
140        }
141        if (this.muted) {
142            this._clearAmbienceTimer();
143        } else if (this.ambienceWanted) {
144            this.startAmbience();
145        }
146    };
147
148    SoundManager.prototype.toggleMute = function () {
149        this.setMuted(!this.muted);
150        return this.muted;
151    };
152
153    SoundManager.prototype.updateMuteButton = function (btn) {
154        if (!btn) return;
155        btn.setAttribute('aria-pressed', this.muted ? 'true' : 'false');
156        btn.setAttribute(
157            'aria-label',
158            this.muted ? (I18N.soundOn || 'Slå på ljud') : (I18N.soundOff || 'Stäng av ljud')
159        );
160        var icon = btn.querySelector('[data-mute-icon]');
161        if (icon) {
162            icon.textContent = this.muted ? '🔇' : '🔊';
163        }
164    };
165
166    SoundManager.prototype.play = function (fn) {
167        if (this.muted) return;
168        this.unlock();
169        var ctx = this.ensureContext();
170        if (!ctx) return;
171        if (ctx.state === 'suspended') {
172            ctx.resume();
173        }
174        try {
175            fn(ctx);
176        } catch (e) {
177            /* ignore */
178        }
179    };
180
181    SoundManager.prototype.applyEnvelope = function (
182        gain,
183        ctx,
184        start,
185        attack,
186        release,
187        peak
188    ) {
189        gain.gain.setValueAtTime(0.0001, start);
190        gain.gain.exponentialRampToValueAtTime(peak, start + attack);
191        gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + release);
192    };
193
194    SoundManager.prototype.flap = function () {
195        var self = this;
196        this.play(function (ctx) {
197            var t = ctx.currentTime;
198            var osc = ctx.createOscillator();
199            var gain = ctx.createGain();
200            osc.type = 'sine';
201            osc.frequency.setValueAtTime(520, t);
202            osc.frequency.exponentialRampToValueAtTime(280, t + 0.07);
203            self.applyEnvelope(gain, ctx, t, 0.008, 0.07, 0.22);
204            osc.connect(gain);
205            gain.connect(ctx.destination);
206            osc.start(t);
207            osc.stop(t + 0.1);
208        });
209    };
210
211    SoundManager.prototype.score = function () {
212        var self = this;
213        this.play(function (ctx) {
214            var t = ctx.currentTime;
215            [523.25, 659.25].forEach(function (freq, i) {
216                var osc = ctx.createOscillator();
217                var gain = ctx.createGain();
218                osc.type = 'triangle';
219                osc.frequency.value = freq;
220                var start = t + i * 0.06;
221                self.applyEnvelope(gain, ctx, start, 0.005, 0.09, 0.18);
222                osc.connect(gain);
223                gain.connect(ctx.destination);
224                osc.start(start);
225                osc.stop(start + 0.12);
226            });
227        });
228    };
229
230    SoundManager.prototype.hit = function () {
231        var self = this;
232        this.play(function (ctx) {
233            var t = ctx.currentTime;
234            var bufferSize = ctx.sampleRate * 0.12;
235            var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
236            var data = buffer.getChannelData(0);
237            for (var i = 0; i < bufferSize; i++) {
238                data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
239            }
240            var noise = ctx.createBufferSource();
241            noise.buffer = buffer;
242            var noiseGain = ctx.createGain();
243            self.applyEnvelope(noiseGain, ctx, t, 0.002, 0.12, 0.35);
244
245            var osc = ctx.createOscillator();
246            var oscGain = ctx.createGain();
247            osc.type = 'sine';
248            osc.frequency.setValueAtTime(180, t);
249            osc.frequency.exponentialRampToValueAtTime(60, t + 0.18);
250            self.applyEnvelope(oscGain, ctx, t, 0.005, 0.2, 0.4);
251
252            noise.connect(noiseGain);
253            noiseGain.connect(ctx.destination);
254            osc.connect(oscGain);
255            oscGain.connect(ctx.destination);
256            noise.start(t);
257            noise.stop(t + 0.15);
258            osc.start(t);
259            osc.stop(t + 0.25);
260        });
261    };
262
263    SoundManager.prototype.ui = function () {
264        var self = this;
265        this.play(function (ctx) {
266            var t = ctx.currentTime;
267            var osc = ctx.createOscillator();
268            var gain = ctx.createGain();
269            osc.type = 'sine';
270            osc.frequency.value = 880;
271            self.applyEnvelope(gain, ctx, t, 0.003, 0.04, 0.12);
272            osc.connect(gain);
273            gain.connect(ctx.destination);
274            osc.start(t);
275            osc.stop(t + 0.06);
276        });
277    };
278
279    SoundManager.prototype.ensureAmbienceBus = function (ctx) {
280        if (this.ambienceGain) return this.ambienceGain;
281        this.ambienceGain = ctx.createGain();
282        this.ambienceGain.gain.value = 0.55;
283        this.ambienceGain.connect(ctx.destination);
284        return this.ambienceGain;
285    };
286
287    SoundManager.prototype._clearAmbienceTimer = function () {
288        if (this._ambienceTimer) {
289            clearTimeout(this._ambienceTimer);
290            this._ambienceTimer = null;
291        }
292    };
293
294    SoundManager.prototype._chirpNote = function (ctx, bus, start, freq, duration, peak) {
295        var osc = ctx.createOscillator();
296        var gain = ctx.createGain();
297        osc.type = 'sine';
298        osc.frequency.setValueAtTime(freq, start);
299        gain.gain.setValueAtTime(0.0001, start);
300        gain.gain.exponentialRampToValueAtTime(peak, start + 0.006);
301        gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
302        osc.connect(gain);
303        gain.connect(bus);
304        osc.start(start);
305        osc.stop(start + duration + 0.02);
306    };
307
308    SoundManager.prototype._playBirdChirp = function (ctx) {
309        if (!this.ambienceActive || this.muted) return;
310        var bus = this.ensureAmbienceBus(ctx);
311        var t = ctx.currentTime;
312        var baseFreq = 1700 + Math.random() * 1600;
313        var vol = 0.007 + Math.random() * 0.006;
314        var pattern = Math.floor(Math.random() * 4);
315
316        if (pattern === 0) {
317            this._chirpNote(ctx, bus, t, baseFreq, 0.09, vol);
318        } else if (pattern === 1) {
319            this._chirpNote(ctx, bus, t, baseFreq, 0.07, vol);
320            this._chirpNote(ctx, bus, t + 0.11, baseFreq * 1.18, 0.08, vol * 0.9);
321        } else if (pattern === 2) {
322            var i;
323            for (i = 0; i < 5; i++) {
324                this._chirpNote(
325                    ctx,
326                    bus,
327                    t + i * 0.055,
328                    baseFreq + (i % 2) * 140,
329                    0.05,
330                    vol * 0.85
331                );
332            }
333        } else {
334            var osc = ctx.createOscillator();
335            var gain = ctx.createGain();
336            osc.type = 'triangle';
337            osc.frequency.setValueAtTime(baseFreq * 0.88, t);
338            osc.frequency.linearRampToValueAtTime(baseFreq * 1.22, t + 0.07);
339            osc.frequency.linearRampToValueAtTime(baseFreq * 0.92, t + 0.22);
340            gain.gain.setValueAtTime(0.0001, t);
341            gain.gain.exponentialRampToValueAtTime(vol, t + 0.01);
342            gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.24);
343            osc.connect(gain);
344            gain.connect(bus);
345            osc.start(t);
346            osc.stop(t + 0.26);
347        }
348    };
349
350    SoundManager.prototype._scheduleNextBirdChirp = function () {
351        var self = this;
352        this._clearAmbienceTimer();
353        if (!this.ambienceActive || this.muted) return;
354
355        var delayMs = 1600 + Math.random() * 5200;
356        this._ambienceTimer = setTimeout(function () {
357            if (!self.ambienceActive || self.muted || !self.ctx) return;
358            self._playBirdChirp(self.ctx);
359            if (Math.random() < 0.32) {
360                setTimeout(function () {
361                    if (self.ambienceActive && !self.muted && self.ctx) {
362                        self._playBirdChirp(self.ctx);
363                    }
364                }, 280 + Math.random() * 420);
365            }
366            self._scheduleNextBirdChirp();
367        }, delayMs);
368    };
369
370    SoundManager.prototype.startAmbience = function () {
371        this.ambienceWanted = true;
372        if (this.muted) return;
373        if (this.ambienceActive) {
374            this._scheduleNextBirdChirp();
375            return;
376        }
377
378        this.unlock();
379        var ctx = this.ensureContext();
380        if (!ctx) return;
381        if (ctx.state === 'suspended') {
382            ctx.resume();
383        }
384
385        this.ensureAmbienceBus(ctx);
386        this.ambienceActive = true;
387        this._scheduleNextBirdChirp();
388    };
389
390    SoundManager.prototype.stopAmbience = function () {
391        this.ambienceWanted = false;
392        this.ambienceActive = false;
393        this._clearAmbienceTimer();
394    };
395
396    var sound = new SoundManager();
397
398    document.addEventListener('visibilitychange', function () {
399        if (!sound.ambienceWanted || !sound.ctx) return;
400        if (document.hidden) {
401            if (sound.ctx.state === 'running') {
402                sound.ctx.suspend();
403            }
404        } else {
405            sound.unlock();
406            if (sound.ctx.state === 'suspended') {
407                sound.ctx.resume();
408            }
409        }
410    });
411
412    /* ---------------------------------------------------------------------- *
413     *  Pre-renderad trana
414     *
415     *  Vi ritar tranan en gång till en off-screen canvas och drar sedan den
416     *  bilden varje frame. Det är snabbare än att rita ~30 path-operationer
417     *  60 gånger per sekund, särskilt på mobil.
418     *
419     *  Designen följer referensbilden: vit kropp/vingar, svart hals och
420     *  huvudtopp, vit kind, röd hätta, lång grå näbb, svarta ben.
421     * ---------------------------------------------------------------------- */
422
423    function createCraneSprite() {
424        var W = 120;
425        var H = 90;
426        var c = document.createElement('canvas');
427        c.width = W;
428        c.height = H;
429        var ctx = c.getContext('2d');
430
431        // Antialiasing av path's
432        ctx.lineJoin = 'round';
433        ctx.lineCap = 'round';
434
435        // OBS: Vinge och ben ritas INTE här – de animeras varje frame
436        // i drawCraneWing() respektive drawCraneLegs() för att kunna
437        // flaxa och hålla stenen pendlande.
438
439        // ---- Kropp (vit oval) ----
440        ctx.fillStyle = '#f4f1e6';
441        ctx.strokeStyle = '#b8b3a0';
442        ctx.lineWidth = 1.5;
443        ctx.beginPath();
444        ctx.ellipse(58, 54, 30, 18, -0.05, 0, Math.PI * 2);
445        ctx.fill();
446        ctx.stroke();
447
448        // ---- Svart "byx-fjäder" baktill (klassisk trana-look) ----
449        ctx.fillStyle = '#202020';
450        ctx.beginPath();
451        ctx.moveTo(34, 50);
452        ctx.quadraticCurveTo(20, 56, 18, 66);
453        ctx.quadraticCurveTo(28, 64, 36, 62);
454        ctx.quadraticCurveTo(40, 56, 38, 50);
455        ctx.closePath();
456        ctx.fill();
457
458        // ---- Hals (S-formad, svart) ----
459        ctx.strokeStyle = '#1a1a1a';
460        ctx.lineWidth = 9;
461        ctx.lineCap = 'round';
462        ctx.beginPath();
463        ctx.moveTo(80, 48);
464        ctx.quadraticCurveTo(96, 40, 96, 26);
465        ctx.stroke();
466
467        // Vit kind / strupe-linje på halsens framsida
468        ctx.strokeStyle = '#f4f1e6';
469        ctx.lineWidth = 3;
470        ctx.beginPath();
471        ctx.moveTo(86, 46);
472        ctx.quadraticCurveTo(94, 38, 96, 30);
473        ctx.stroke();
474
475        // ---- Huvud (ovalt, svart med vit kind) ----
476        ctx.fillStyle = '#1a1a1a';
477        ctx.beginPath();
478        ctx.ellipse(98, 22, 10, 8, 0.1, 0, Math.PI * 2);
479        ctx.fill();
480
481        // Vit kind-fläck
482        ctx.fillStyle = '#f4f1e6';
483        ctx.beginPath();
484        ctx.ellipse(100, 24, 5, 3.4, 0.2, 0, Math.PI * 2);
485        ctx.fill();
486
487        // ---- Röd hätta på hjässan ----
488        ctx.fillStyle = '#d94f3b';
489        ctx.beginPath();
490        ctx.ellipse(96, 15, 5, 3, -0.25, 0, Math.PI * 2);
491        ctx.fill();
492
493        // ---- Näbb (lång, grå-gul, spetsig) ----
494        ctx.fillStyle = '#c9a86b';
495        ctx.strokeStyle = '#7c6230';
496        ctx.lineWidth = 1;
497        ctx.beginPath();
498        ctx.moveTo(106, 20);
499        ctx.lineTo(120, 22);
500        ctx.lineTo(106, 25);
501        ctx.closePath();
502        ctx.fill();
503        ctx.stroke();
504
505        // Näbb-mittlinje
506        ctx.strokeStyle = 'rgba(0,0,0,0.5)';
507        ctx.beginPath();
508        ctx.moveTo(106, 23);
509        ctx.lineTo(120, 22);
510        ctx.stroke();
511
512        // ---- Öga ----
513        ctx.fillStyle = '#fff';
514        ctx.beginPath();
515        ctx.arc(102, 21, 1.6, 0, Math.PI * 2);
516        ctx.fill();
517        ctx.fillStyle = '#000';
518        ctx.beginPath();
519        ctx.arc(102.3, 21.2, 0.9, 0, Math.PI * 2);
520        ctx.fill();
521
522        return {
523            canvas: c,
524            width: W,
525            height: H,
526            // Tranan är ritad pekande åt höger.
527            // Hitbox används i kollisionsdetekteringen (snäv för rättvis känsla).
528            hitbox: { x: -22, y: -14, w: 44, h: 28 },
529            // Ankarpunkter i lokala koordinater (relativt punkten där
530            // sprite-pixel (58,45) hamnar – dvs spelarens position).
531            shoulder: { x: -4, y: -3 },
532            hip:      { x: 4,  y: 13 },
533        };
534    }
535
536    /**
537     * Ritar tranans vinge. Anropas i tranans lokala koordinatsystem (efter
538     * ctx.translate till spelarens position + ctx.rotate(bodyRotation)).
539     *
540     * @param {CanvasRenderingContext2D} ctx
541     * @param {number} angle  Aktuell vingvinkel i radianer.
542     *                        -1 ≈ full upp-stroke, +1 ≈ full down-stroke.
543     * @param {{x:number,y:number}} shoulder Skulder-fäste.
544     */
545    function drawCraneWing(ctx, angle, shoulder) {
546        ctx.save();
547        ctx.translate(shoulder.x, shoulder.y);
548        ctx.rotate(angle);
549
550        // Vingen ritas i sin egen ram: skulderfästet är (0,0), vingen sträcker
551        // sig bakåt (negativ x = bakåt eftersom tranan pekar åt höger) och nedåt.
552        // I "neutral" position (angle ≈ 0) ligger den utsträckt horisontellt.
553
554        // Vingen som ett blad – primärfjädrar och täckfjädrar
555        ctx.fillStyle = '#fbf8ee';
556        ctx.strokeStyle = '#9f9a86';
557        ctx.lineWidth = 1.5;
558        ctx.lineJoin = 'round';
559        ctx.beginPath();
560        ctx.moveTo(0, 0);
561        // Framkant (övre kant) – böjd uppåt
562        ctx.quadraticCurveTo(-10, -3, -22, 2);
563        // Vingspets
564        ctx.quadraticCurveTo(-26, 6, -20, 10);
565        // Bakkant
566        ctx.quadraticCurveTo(-10, 14, 2, 6);
567        ctx.closePath();
568        ctx.fill();
569        ctx.stroke();
570
571        // Svart vingspets (primärfjädrar) – klassisk trana-detalj
572        ctx.fillStyle = '#1f1f1f';
573        ctx.beginPath();
574        ctx.moveTo(-16, 4);
575        ctx.quadraticCurveTo(-26, 6, -20, 10);
576        ctx.quadraticCurveTo(-14, 11, -16, 4);
577        ctx.closePath();
578        ctx.fill();
579
580        // Fjäderlinjer
581        ctx.strokeStyle = 'rgba(40,40,40,0.35)';
582        ctx.lineWidth = 0.8;
583        ctx.beginPath();
584        ctx.moveTo(-4, 2);
585        ctx.quadraticCurveTo(-12, 6, -18, 8);
586        ctx.moveTo(-2, 6);
587        ctx.quadraticCurveTo(-10, 10, -16, 11);
588        ctx.stroke();
589
590        ctx.restore();
591    }
592
593    /**
594     * Ritar tranans ben och den vinröda granitstenen som hänger i foten.
595     * Stenen pendlar mjukt baserat på vertikal acceleration.
596     *
597     * @param {CanvasRenderingContext2D} ctx
598     * @param {number} swing  Stenens pendelvinkel i radianer.
599     * @param {{x:number,y:number}} hip Höft-fäste.
600     */
601    function drawCraneLegs(ctx, swing, hip) {
602        var hx = hip.x;
603        var hy = hip.y;
604
605        ctx.save();
606        ctx.lineCap = 'round';
607        ctx.lineJoin = 'round';
608
609        // ---- Bakre ben (utan sten) ----
610        // Hänger nästan rakt ned-bakåt, sträckt och avslappnat.
611        // Mörkare grå för att signalera att det är bakom kroppen.
612        ctx.strokeStyle = '#2a2a2a';
613        ctx.lineWidth = 1.7;
614        ctx.beginPath();
615        ctx.moveTo(hx - 5, hy - 1);   // höft
616        ctx.lineTo(hx - 8, hy + 13);  // knä (lätt bakåt)
617        ctx.lineTo(hx - 11, hy + 28); // fotled
618        ctx.stroke();
619
620        // Bakre fot (liten "L" pekande bakåt)
621        ctx.beginPath();
622        ctx.moveTo(hx - 11, hy + 28);
623        ctx.lineTo(hx - 16, hy + 28);
624        ctx.moveTo(hx - 11, hy + 28);
625        ctx.lineTo(hx - 9, hy + 29);
626        ctx.stroke();
627
628        // ---- Främre ben (håller stenen) ----
629        // Tydligt böjt framåt så foten + stenen syns framför kroppen.
630        ctx.strokeStyle = '#1a1a1a';
631        ctx.lineWidth = 2;
632        ctx.beginPath();
633        ctx.moveTo(hx, hy - 2);       // höft
634        ctx.lineTo(hx + 3, hy + 13);  // knä (något framåt)
635        ctx.lineTo(hx + 7, hy + 28);  // fotled, framflyttad
636        ctx.stroke();
637
638        // Klor som griper om stenen
639        ctx.beginPath();
640        ctx.moveTo(hx + 7, hy + 28);
641        ctx.lineTo(hx + 3, hy + 29);   // bak-klo
642        ctx.moveTo(hx + 7, hy + 28);
643        ctx.lineTo(hx + 11, hy + 30);  // fram-klo
644        ctx.stroke();
645
646        // ---- Vinröd granitsten ----
647        // Pendelpunkt i klorna på det främre benet.
648        var anchorX = hx + 7;
649        var anchorY = hy + 29;
650
651        ctx.translate(anchorX, anchorY);
652        ctx.rotate(swing);
653
654        var sx = 0;
655        var sy = 4;
656        var sw = 6;
657        var sh = 4.5;
658
659        // Mjuk skugga under stenen
660        ctx.fillStyle = 'rgba(0,0,0,0.18)';
661        ctx.beginPath();
662        ctx.ellipse(sx + 0.5, sy + 1.5, sw + 0.5, sh * 0.5, 0, 0, Math.PI * 2);
663        ctx.fill();
664
665        // Stenens kropp – mörk vinröd gradient
666        var grad = ctx.createRadialGradient(sx - 1.5, sy - 1.5, 0.5, sx, sy, sw + 1);
667        grad.addColorStop(0, '#9a2a35');
668        grad.addColorStop(0.55, '#6e1820');
669        grad.addColorStop(1, '#3a0a10');
670        ctx.fillStyle = grad;
671        ctx.strokeStyle = '#260608';
672        ctx.lineWidth = 0.8;
673        ctx.beginPath();
674        ctx.ellipse(sx, sy, sw, sh, 0.15, 0, Math.PI * 2);
675        ctx.fill();
676        ctx.stroke();
677
678        // Granit-spräckling – små mörka och ljusa prickar
679        ctx.fillStyle = 'rgba(20, 10, 12, 0.55)';
680        ctx.beginPath();
681        ctx.arc(sx - 2, sy - 0.5, 0.7, 0, Math.PI * 2);
682        ctx.arc(sx + 1.2, sy + 1.5, 0.85, 0, Math.PI * 2);
683        ctx.arc(sx + 2.6, sy - 1, 0.55, 0, Math.PI * 2);
684        ctx.fill();
685
686        ctx.fillStyle = 'rgba(255, 200, 200, 0.35)';
687        ctx.beginPath();
688        ctx.arc(sx - 1, sy - 1.8, 0.6, 0, Math.PI * 2);
689        ctx.arc(sx + 2.2, sy + 0.4, 0.45, 0, Math.PI * 2);
690        ctx.fill();
691
692        // Highlight på toppen
693        ctx.fillStyle = 'rgba(255, 220, 220, 0.28)';
694        ctx.beginPath();
695        ctx.ellipse(sx - 1.4, sy - 2.4, sw * 0.55, sh * 0.32, 0.1, 0, Math.PI * 2);
696        ctx.fill();
697
698        ctx.restore();
699    }
700
701    /* ---------------------------------------------------------------------- *
702     *  Lagrade bakgrundselement (parallax)
703     * ---------------------------------------------------------------------- */
704
705    function createCloudSprite() {
706        var c = document.createElement('canvas');
707        c.width = 120;
708        c.height = 60;
709        var g = c.getContext('2d');
710        g.fillStyle = 'rgba(255,255,255,0.9)';
711        g.beginPath();
712        g.arc(30, 38, 20, 0, Math.PI * 2);
713        g.arc(55, 30, 26, 0, Math.PI * 2);
714        g.arc(82, 36, 22, 0, Math.PI * 2);
715        g.arc(100, 42, 16, 0, Math.PI * 2);
716        g.fill();
717        return c;
718    }
719
720    function createMountainLayer(width, height, color, peaks, seed) {
721        var c = document.createElement('canvas');
722        c.width = width;
723        c.height = height;
724        var g = c.getContext('2d');
725        g.fillStyle = color;
726        g.beginPath();
727        g.moveTo(0, height);
728        var rng = mulberry32(seed);
729        var step = width / peaks;
730        for (var i = 0; i <= peaks; i++) {
731            var x = i * step;
732            var y = height - (height * 0.4 + rng() * height * 0.55);
733            g.lineTo(x, y);
734        }
735        g.lineTo(width, height);
736        g.closePath();
737        g.fill();
738        return c;
739    }
740
741    function createTreeLineLayer(width, height, color, seed) {
742        var c = document.createElement('canvas');
743        c.width = width;
744        c.height = height;
745        var g = c.getContext('2d');
746        var rng = mulberry32(seed);
747        for (var x = 0; x < width; x += 14) {
748            var th = height * (0.55 + rng() * 0.45);
749            var tw = 12 + rng() * 6;
750            g.fillStyle = color;
751            // Stiliserade gran-/lövträd
752            g.beginPath();
753            g.moveTo(x, height);
754            g.lineTo(x + tw / 2, height - th);
755            g.lineTo(x + tw, height);
756            g.closePath();
757            g.fill();
758        }
759        return c;
760    }
761
762    /**
763     * Bygger en stadssiluett med tända fönster och gatuljus.
764     *
765     * Returnerar två canvasar:
766     *   - `canvas`: själva byggnaderna i en mörk silhuettfärg (den ritas
767     *              alltid och tonas av ambient-tinten precis som bergen).
768     *   - `lights`: ett separat lager där fönsterglim och gatuljus redan
769     *              är förrenderade som radiella gradienter. Det lagret
770     *              drawImage:as additivt (globalCompositeOperation =
771     *              'lighter') efter ambient-tinten med en alpha som
772     *              följer dagscykeln, så ljusen tänds gradvis mot natten
773     *              och lyser starkt rakt genom mörkret.
774     *
775     * Genom att baka in alla glows i ett enda canvas-lager renderar vi
776     * hela stadens lyse med en (1) drawImage per parallax-tile per frame
777     * istället för dussintals radiella gradienter – billigt och snyggt.
778     */
779    function createCitySilhouetteLayer(width, height, color, seed) {
780        var silhouette = document.createElement('canvas');
781        silhouette.width = width;
782        silhouette.height = height;
783        var sg = silhouette.getContext('2d');
784
785        var lights = document.createElement('canvas');
786        lights.width = width;
787        lights.height = height;
788        var lg = lights.getContext('2d');
789
790        var rng = mulberry32(seed);
791
792        // Liten hjälpfunktion för att rita ett radiellt sken på ljus-canvasen.
793        function glow(x, y, radius, rgb, innerAlpha) {
794            var grad = lg.createRadialGradient(x, y, 0, x, y, radius);
795            grad.addColorStop(0, 'rgba(' + rgb + ',' + innerAlpha + ')');
796            grad.addColorStop(1, 'rgba(' + rgb + ', 0)');
797            lg.fillStyle = grad;
798            lg.beginPath();
799            lg.arc(x, y, radius, 0, Math.PI * 2);
800            lg.fill();
801        }
802
803        var groundY = height;
804        var pendingLampSlots = [];
805
806        var x = 0;
807        while (x < width) {
808            var bw = 32 + Math.floor(rng() * 56); // 32..88
809            if (x + bw > width) bw = width - x;
810            if (bw < 18) break;
811
812            var bh = Math.floor(height * (0.32 + rng() * 0.62)); // 32..94 %
813            if (bh > height - 4) bh = height - 4;
814            var by = groundY - bh;
815
816            // Stomme
817            sg.fillStyle = color;
818            sg.fillRect(x, by, bw, bh);
819
820            // Tak-varianter (flat / trappstegs-attika / antenn / 
820spetsigt)
821            var roofKind = Math.floor(rng() * 4);
822            if (roofKind === 1) {
823                var stepW = Math.max(8, Math.floor(bw * (0.35 + rng() * 0.3)));
824                var stepH = 5 + Math.floor(rng() * 14);
825                var stepX = x + Math.floor((bw - stepW) * rng());
826                sg.fillRect(stepX, by - stepH, stepW, stepH);
827            } else if (roofKind === 2) {
828                var antennaH = 10 + Math.floor(rng() * 16);
829                sg.fillRect(x + Math.floor(bw / 2), by - antennaH, 1, antennaH);
830                // Liten blinkare i toppen – varmröd
831                glow(x + Math.floor(bw / 2) + 0.5, by - antennaH, 4, '255, 90, 70', 0.85);
832            } else if (roofKind === 3) {
833                sg.beginPath();
834                sg.moveTo(x, by);
835                sg.lineTo(x + bw / 2, by - 8 - rng() * 14);
836                sg.lineTo(x + bw, by);
837                sg.closePath();
838                sg.fill();
839            }
840
841            // Fönster (om huset är tillräckligt stort)
842            if (bh > 26 && bw > 16) {
843                var winW = 3;
844                var winH = 4;
845                var gapX = 6;
846                var gapY = 8;
847                // Rasterets startpunkt – centrera lite snyggt
848                var startWx = x + 3 + Math.floor(rng() * 2);
849                var startWy = by + 5 + Math.floor(rng() * 3);
850                for (var wy = startWy; wy + winH < groundY - 3; wy += gapY) {
851                    for (var wx = startWx; wx + winW < x + bw - 2; wx += gapX) {
852                        // Mörk fönsterruta på siluetten (lite kontrast även dagtid)
853                        sg.fillStyle = 'rgba(0, 0, 0, 0.30)';
854                        sg.fillRect(wx, wy, winW, winH);
855
856                        // Slumpa om fönstret är tänt
857                        if (rng() < 0.55) {
858                            var hue = rng();
859                            var rgbGlow, rgbCore;
860                            if (hue < 0.7) {
861                                rgbGlow = '255, 210, 130'; // varmgul
862                                rgbCore = '255, 240, 190';
863                            } else if (hue < 0.92) {
864                                rgbGlow = '255, 165, 90';  // amber
865                                rgbCore = '255, 220, 170';
866                            } else {
867                                rgbGlow = '180, 220, 255'; // kall vit (kontor)
868                                rgbCore = '230, 240, 255';
869                            }
870                            var cx = wx + winW / 2;
871                            var cy = wy + winH / 2;
872                            // Mjukt sken
873                            glow(cx, cy, 6 + rng() * 2, rgbGlow, 0.55);
874                            // Hård kärna i fönsterglaset
875                            lg.fillStyle = 'rgba(' + rgbCore + ', 0.95)';
876                            lg.fillRect(wx, wy, winW, winH);
877                        }
878                    }
879                }
880            }
881
882            x += bw + 1;
883
884            // Spara plats för en gatlykta i gluggen mellan husen
885            if (rng() < 0.55 && x < width - 12) {
886                pendingLampSlots.push(x + 3 + Math.floor(rng() * 4));
887                x += 9; // bredare lucka för lyktan
888            }
889        }
890
891        // Rita gatuljusen – lyktstolpe (på siluetten) + sken (på ljuslagret)
892        for (var i = 0; i < pendingLampSlots.length; i++) {
893            var lx = pendingLampSlots[i];
894            var lampH = 16 + Math.floor(rng() * 8);
895            var headY = groundY - lampH;
896
897            // Stolpe + huvud i mörkfärg på siluetten
898            sg.fillStyle = '#0e1422';
899            sg.fillRect(lx, headY, 1, lampH);
900            sg.fillRect(lx - 2, headY - 2, 5, 2);
901            // Tunn arm (lyktans utstickare)
902            sg.fillRect(lx + 1, headY - 1, 3, 1);
903
904            // Ljus från lyktan
905            var lampX = lx + 0.5;
906            var lampY = headY - 1;
907            glow(lampX, lampY, 16, '255, 215, 140', 0.85);
908            glow(lampX, lampY, 7, '255, 240, 200', 0.95);
909            // Hård bländningskärna
910            lg.fillStyle = 'rgba(255, 245, 215, 1)';
911            lg.beginPath();
912            lg.arc(lampX, lampY, 1.4, 0, Math.PI * 2);
913            lg.fill();
914
915            // Nedåtriktad ljuskon mot gatan
916            var coneTopY = lampY + 1;
917            var coneBotY = groundY + 2;
918            if (coneBotY > height) coneBotY = height;
919            var coneGrad = lg.createLinearGradient(lampX, coneTopY, lampX, coneBotY);
920            coneGrad.addColorStop(0, 'rgba(255, 220, 150, 0.55)');
921            coneGrad.addColorStop(1, 'rgba(255, 220, 150, 0)');
922            lg.fillStyle = coneGrad;
923            lg.beginPath();
924            lg.moveTo(lampX, coneTopY);
925            lg.lineTo(lampX + 7, coneBotY);
926            lg.lineTo(lampX - 7, coneBotY);
927            lg.closePath();
928            lg.fill();
929        }
930
931        return { canvas: silhouette, lights: lights };
932    }
933
934    function mulberry32(seed) {
935        var a = seed >>> 0;
936        return function () {
937            a |= 0;
938            a = (a + 0x6d2b79f5) | 0;
939            var t = a;
940            t = Math.imul(t ^ (t >>> 15), t | 1);
941            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
942            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
943        };
944    }
945
946    function createStarsLayer(width, height, count, seed) {
947        var c = document.createElement('canvas');
948        c.width = width;
949        c.height = height;
950        var g = c.getContext('2d');
951        var rng = mulberry32(seed);
952        for (var i = 0; i < count; i++) {
953            var x = rng() * width;
954            var y = rng() * height * 0.75; // håll dem i övre delen
955            var r = 0.4 + rng() * 1.4;
956            var b = 0.55 + rng() * 0.45;
957            g.fillStyle = 'rgba(255,255,250,' + b.toFixed(2) + ')';
958            g.beginPath();
959            g.arc(x, y, r, 0, Math.PI * 2);
960            g.fill();
961            // Glittrande extra glow på de större stjärnorna
962            if (r > 1.2) {
963                g.fillStyle = 'rgba(255,255,250,' + (b * 0.18).toFixed(2) + ')';
964                g.beginPath();
965                g.arc(x, y, r * 2.5, 0, Math.PI * 2);
966                g.fill();
967            }
968        }
969        return c;
970    }
971
972    /* ---------------------------------------------------------------------- *
973     *  Dag/natt-cykel
974     *
975     *  En full cykel är 120 sekunder och består av fyra 30-sekunders faser:
976     *    0   ..  30  morgon
977     *    30  ..  60  eftermiddag
978     *    60  ..  90  kväll
979     *    90  .. 120  natt (loopar tillbaka till morgon)
980     *
981     *  Färgerna lerpas (smoothstep) mellan faser så övergångarna är mjuka.
982     * ---------------------------------------------------------------------- */
983
984    var DAY_CYCLE_LENGTH = 120;
985    var PHASE_LENGTH = 30;
986
987    // top, mid (~55%), bot för himmel-gradienten
988    var SKY_PALETTES = [
989        // Morgon – rosa/peach mot blå
990        { top: [127, 178, 215], mid: [255, 198, 170], bot: [255, 232, 196] },
991        // Eftermiddag – klar dagsblå
992        { top: [70, 150, 205],  mid: [134, 196, 224], bot: [200, 232, 240] },
993        // Kväll – solnedgång (lila, orange, gyllene)
994        { top: [50, 40, 90],    mid: [220, 110, 70],  bot: [255, 190, 110] },
995        // Natt – djup mörkblå/svart för att stadsljusen ska poppa
996        { top: [3, 6, 18],      mid: [8, 14, 32],     bot: [16, 24, 48]   },
997    ];
998
999    // Foreground ambient tint (r,g,b,alpha). Lerpas över faser så
1000    // marken/bergen/spelaren naturligt mörknar mot natten. Lyktor och
1001    // fönster tonas INTE av detta lager – de ritas additivt efteråt.
1002    var AMBIENT_TINT = [
1003        [0, 0, 0, 0],          // morgon – ingen tint
1004        [255, 255, 255, 0],    // eftermiddag – ingen tint
1005        [30, 12, 45, 0.22],    // kväll – varm-lila skugga
1006        [2, 6, 24, 0.68],      // natt – kraftig mörkblå, stadssiluetten blir nästan svart
1007    ];
1008
1009    function lerp(a, b, t) {
1010        return a + (b - a) * t;
1011    }
1012
1013    function lerpRgb(a, b, t) {
1014        return [
1015            a[0] + (b[0] - a[0]) * t,
1016            a[1] + (b[1] - a[1]) * t,
1017            a[2] + (b[2] - a[2]) * t,
1018        ];
1019    }
1020
1021    function rgbCss(c) {
1022        return 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')';
1023    }
1024
1025    function rgbaCss(c, a) {
1026        return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')';
1027    }
1028
1029    function smoothstep(t) {
1030        return t * t * (3 - 2 * t);
1031    }
1032
1033    /* ---------------------------------------------------------------------- *
1034     *  Game-instans
1035     * ---------------------------------------------------------------------- */
1036
1037    function Game(root) {
1038        this.root = root;
1039        this.canvas = root.querySelector('.taniga-tranan__canvas');
1040        this.ctx = this.canvas.getContext('2d');
1041        this.startScreen = root.querySelector('[data-screen="start"]');
1042        this.gameOverScreen = root.querySelector('[data-screen="gameover"]');
1043        this.finalScoreEl = root.querySelector('[data-final-score]');
1044        this.form = root.querySelector('[data-action="submit-score"]');
1045        this.input = this.form.querySelector('input[name="name"]');
1046        this.messageEl = this.form.querySelector('[data-message]');
1047        this.scoresList = root.querySelector('[data-highscores]');
1048        this.restartBtn = root.querySelector('[data-action="restart"]');
1049        this.startBtn = root.querySelector('[data-action="start"]');
1050
1051        this.state = 'menu'; // menu | playing | dying | gameover
1052        this.dpr = 1;
1053        this.score = 0;
1054        this.best = loadBest();
1055        this.lastSubmittedId = null;
1056        this.lastSubmittedScore = 0;
1057
1058        // Dynamisk världsbredd. Höjden hålls fix på WORLD.height så att
1059        // gravitation, flap-velocity, rörhöjder m.m. känns lika på alla skärmar.
1060        // Bredden beräknas om i resize() utifrån canvasens aspect ratio så
1061        // att spelet kan fylla både portrait och landscape utan att fysiken
1062        // sträcks (scaleX === scaleY).
1063        this.worldWidth = WORLD.width;
1064
1065        this.crane = createCraneSprite();
1066        this.cloud = createCloudSprite();
1067        this.stars = createStarsLayer(this.worldWidth, WORLD.height, 70, 31337);
1068
1069        // Dygnstid: börjar slumpmässigt i cykeln så två sidladdningar inte
1070        // alltid ser likadana ut, men säkerställ att start = morgon-ish.
1071        this.dayTime = 0;
1072
1073        this.bgLayers = this.buildBackgroundLayers();
1074
1075        this.player = {
1076            x: this.worldWidth * 0.3,
1077            y: WORLD.height * 0.45,
1078            vy: 0,
1079            rotation: 0,
1080            flapPhase: 0,
1081            wingPhase: 0,   // ackumulerad fas för flax-cykeln (radianer)
1082            stoneSwing: 0,  // pendelvinkel för stenen
1083        };
1084
1085        this.pipes = [];
1086        this.pipeTimer = 0;
1087        this.currentPipeInterval = WORLD.pipeInterval;
1088        this.groundOffset = 0;
1089        this.cloudOffset = 0;
1090        this.layerOffsets = [0, 0, 0, 0];
1091
1092        this.lastTimestamp = 0;
1093        this.accumulator = 0;
1094        this.fixedStep = 1 / 60;
1095        this.running = false;
1096
1097        this.bindEvents();
1098        this.resize();
1099        this.fetchHighscores();
1100        this.startLoop();
1101    }
1102
1103    Game.prototype.buildBackgroundLayers = function () {
1104        // En distant stad bakom (mindre och blekare) + en närmare stad
1105        // med gatuljus och fler lysande fönster i förgrunden. Bergen i
1106        // bakgrunden ger djup utan att stjäla scenen från staden.
1107        var distantCity = createCitySilhouetteLayer(960, 130, '#1a2236', 4242);
1108        var nearCity    = createCitySilhouetteLayer(960, 200, '#0d1424', 7777);
1109
1110        return [
1111            {
1112                // Distant mountains – mjuk horisont
1113                canvas: createMountainLayer(960, 220, '#3b4a66', 8, 1234),
1114                y: WORLD.height - WORLD.groundHeight - 220,
1115                speedFactor: 0.12,
1116            },
1117            {
1118                // Distant city skyline – små lysande fönster, ingen gatlykta
1119                canvas: distantCity.canvas,
1120                lights: distantCity.lights,
1121                y: WORLD.height - WORLD.groundHeight - 130,
1122                speedFactor: 0.28,
1123                lightAlphaScale: 0.7,
1124            },
1125            {
1126                // Foreground city – stora hus, fönster och gatuljus
1127                canvas: nearCity.canvas,
1128                lights: nearCity.lights,
1129                y: WORLD.height - WORLD.groundHeight - 200,
1130                speedFactor: 0.55,
1131                lightAlphaScale: 1,
1132            },
1133        ];
1134    };
1135
1136    /* ---- Input ---- */
1137
1138    Game.prototype.bindEvents = function () {
1139        var self = this;
1140
1141        // Start-knapp
1142        this.startBtn.addEventListener('click', function () {
1143            sound.unlock();
1144            sound.ui();
1145            self.startGame();
1146        });
1147
1148        // Spela igen
1149        this.restartBtn.addEventListener('click', function () {
1150            sound.unlock();
1151            sound.ui();
1152            self.startGame();
1153        });
1154
1155        // Skicka highscore
1156        this.form.addEventListener('submit', function (ev) {
1157            ev.preventDefault();
1158            self.submitScore();
1159        });
1160
1161        // Pekskärm: flapp på touchstart, men inte på overlays / knappar / inputs.
1162        this.canvas.addEventListener(
1163            'pointerdown',
1164            function (ev) {
1165                ev.preventDefault();
1166                self.handleFlap();
1167            },
1168            { passive: false }
1169        );
1170
1171        // Tangentbord: Mellanslag, Pil-upp, W
1172        window.addEventListener('keydown', function (ev) {
1173            if (!self.isVisibleInViewport()) return;
1174            if (ev.code === 'Space' || ev.code === 'ArrowUp' || ev.code === 'KeyW') {
1175                // Inte flappa när inputen har fokus
1176                if (document.activeElement === self.input) return;
1177                ev.preventDefault();
1178                self.handleFlap();
1179            }
1180        });
1181
1182        // Pause vid visibility change
1183        document.addEventListener('visibilitychange', function () {
1184            if (document.hidden) {
1185                self.running = false;
1186            } else {
1187                self.resumeLoop();
1188            }
1189        });
1190
1191        // Responsiv: lyssna på resize och devicePixelRatio-change
1192        window.addEventListener('resize', function () {
1193            self.resize();
1194        });
1195    };
1196
1197    Game.prototype.isVisibleInViewport = function () {
1198        var rect = this.root.getBoundingClientRect();
1199        return rect.bottom > 0 && rect.top < window.innerHeight;
1200    };
1201
1202    /* ---- Skärmhantering ---- */
1203
1204    Game.prototype.showScreen = function (name) {
1205        this.startScreen.hidden = name !== 'menu';
1206        this.gameOverScreen.hidden = name !== 'gameover';
1207    };
1208
1209    /* ---- Loop ---- */
1210
1211    Game.prototype.startLoop = function () {
1212        var self = this;
1213        this.loopBound = function (ts) {
1214            self.loop(ts);
1215        };
1216        this.rafId = 0;
1217        this.running = true;
1218        this.rafId = requestAnimationFrame(this.loopBound);
1219    };
1220
1221    /**
1222     * Säkerställer att exakt en RAF-loop är schemalagd.
1223     */
1224    Game.prototype.resumeLoop = function () {
1225        this.running = true;
1226        this.lastTimestamp = 0;
1227        this.accumulator = 0;
1228        if (!this.rafId) {
1229            this.rafId = requestAnimationFrame(this.loopBound);
1230        }
1231    };
1232
1233    Game.prototype.loop = function (timestamp) {
1234        this.rafId = 0;
1235        if (!this.running) return;
1236
1237        if (!this.lastTimestamp) this.lastTimestamp = timestamp;
1238        var dt = (timestamp - this.lastTimestamp) / 1000;
1239        // Skydd mot tab-byten eller stora luckor
1240        if (dt > 0.25) dt = 0.25;
1241        this.lastTimestamp = timestamp;
1242
1243        // Fixed timestep med accumulator
1244        this.accumulator += dt;
1245        while (this.accumulator >= this.fixedStep) {
1246            this.update(this.fixedStep);
1247            this.accumulator -= this.fixedStep;
1248        }
1249
1250        this.render();
1251        this.rafId = requestAnimationFrame(this.loopBound);
1252    };
1253
1254    /* ---- Spelflöde ---- */
1255
1256    Game.prototype.startGame = function () {
1257        this.score = 0;
1258        this.pipes = [];
1259        this.pipeTimer = 0;
1260        this.currentPipeInterval = WORLD.pipeInterval;
1261        this.player.x = this.worldWidth * 0.3;
1262        this.player.y = WORLD.height * 0.45;
1263        this.player.vy = 0;
1264        this.player.rotation = 0;
1265        this.player.flapPhase = 0;
1266        this.player.wingPhase = 0;
1267        this.player.stoneSwing = 0;
1268        this.state = 'playing';
1269        this.showScreen('playing');
1270        this.messageEl.hidden = true;
1271        this.messageEl.removeAttribute('data-state');
1272        this.messageEl.textContent = '';
1273        this.resumeLoop();
1274    };
1275
1276    Game.prototype.handleFlap = function () {
1277        sound.unlock();
1278        if (this.state === 'menu') {
1279            this.startGame();
1280            this.player.vy = WORLD.flapVelocity;
1281            sound.flap();
1282            return;
1283        }
1284        if (this.state === 'playing') {
1285            this.player.vy = WORLD.flapVelocity;
1286            this.player.flapPhase = 1;
1287            sound.flap();
1288        }
1289    };
1290
1291    Game.prototype.endGame = function () {
1292        if (this.state === 'gameover') return;
1293        this.state = 'gameover';
1294        sound.hit();
1295        saveBest(this.score);
1296        this.best = loadBest();
1297        this.finalScoreEl.textContent = String(this.score);
1298        this.lastSubmittedScore = this.score;
1299        this.showScreen('gameover');
1300        // Fokusera fältet om vi har poäng värt att spara
1301        if (this.score > 0) {
1302            // setTimeout för att låta overlay renderas innan focus
1303            var input = this.input;
1304            setTimeout(function () {
1305                try {
1306                    input.focus({ preventScroll: true });
1307                } catch (e) {
1308                    input.focus();
1309                }
1310            }, 50);
1311        }
1312    };
1313
1314    /* ---- Update (fixed step) ---- */
1315
1316    Game.prototype.update = function (dt) {
1317        // Dygnstid tickar oavsett tillstånd (även menyn)
1318        this.dayTime = (this.dayTime + dt) % DAY_CYCLE_LENGTH;
1319
1320        // Bakgrund + mark rullar alltid (även i menyn för liv)
1321        this.groundOffset = (this.groundOffset + WORLD.scrollSpeed * dt) % 48;
1322        this.cloudOffset = (this.cloudOffset + WORLD.scrollSpeed * 0.1 * dt) % 1200;
1323
1324        for (var i = 0; i < this.bgLayers.length; i++) {
1325            var L = this.bgLayers[i];
1326            this.layerOffsets[i] =
1327                (this.layerOffsets[i] + WORLD.scrollSpeed * L.speedFactor * dt) %
1328                L.canvas.width;
1329        }
1330
1331        if (this.state === 'menu') {
1332            // Liten hover-rörelse på tranan när vi väntar
1333            this.player.y =
1334                WORLD.height * 0.45 + Math.sin(performance.now() / 400) * 12;
1335            this.player.rotation = Math.sin(performance.now() / 400) * 0.05;
1336            // LÃ¥ngsam vingflax i menyn (~1.5 Hz, lugnt)
1337            this.player.wingPhase += dt * Math.PI * 2 * 1.5;
1338            // Sten hänger lugnt, mild vagg-rörelse
1339            var menuTarget = Math.sin(performance.now() / 600) * 0.06;
1340            this.player.stoneSwing += (menuTarget - this.player.stoneSwing) * 0.08;
1341            return;
1342        }
1343
1344        if (this.state === 'gameover') {
1345            // Vingen stannar; stenen dinglar mot 0
1346            this.player.stoneSwing *= 0.92;
1347            return;
1348        }
1349
1350        if (this.state !== 'playing') return;
1351
1352        // Fysik
1353        this.player.vy += WORLD.gravity * dt;
1354        if (this.player.vy > WORLD.maxFallSpeed) this.player.vy = WORLD.maxFallSpeed;
1355        this.player.y += this.player.vy * dt;
1356
1357        // Rotation följer velocity (uppåt = nosa upp, nedåt = nosa ned)
1358        var targetRot = clamp(this.player.vy / 600, -0.45, 1.1);
1359        this.player.rotation += (targetRot - this.player.rotation) * 0.15;
1360
1361        // Flax-frekvens: lugn bas ~2.5 Hz, kort boost upp till ~5 Hz precis
1362        // efter en flap (mer trana-likt än spurv-likt).
1363        var wingFreq = 2.5 + this.player.flapPhase * 2.5;
1364        this.player.wingPhase += dt * Math.PI * 2 * wingFreq;
1365
1366        // Stenen pendlar baserat på vertikal velocity (känsla av tyngd).
1367        // Negativ vy (rörelse uppåt) => stenen släpar bakåt (positiv swing
1368        // = medurs i canvas, dvs nedåt och åt vänster = bakåt).
1369        var swingTarget = clamp(-this.player.vy / 700, -0.5, 0.5) +
1370            this.player.flapPhase * 0.25;
1371        this.player.stoneSwing += (swingTarget - this.player.stoneSwing) * 0.12;
1372
1373        // Flax-animation toner ut
1374        this.player.flapPhase *= 0.9;
1375
1376        // Generera nya rör
1377        this.pipeTimer += dt;
1378        if (this.pipeTimer >= this.currentPipeInterval) {
1379            this.pipeTimer = 0;
1380            this.spawnPipe();
1381            this.currentPipeInterval = Math.max(
1382                WORLD.minPipeInterval,
1383                this.currentPipeInterval * WORLD.difficultyRamp
1384            );
1385        }
1386
1387        // Uppdatera rör + poäng
1388        for (var p = this.pipes.length - 1; p >= 0; p--) {
1389            var pipe = this.pipes[p];
1390            pipe.x -= WORLD.scrollSpeed * dt;
1391
1392            if (!pipe.scored && pipe.x + WORLD.pipeWidth < this.player.x) {
1393                pipe.scored = true;
1394                this.score += 1;
1395                sound.score();
1396            }
1397
1398            if (pipe.x + WORLD.pipeWidth < -20) {
1399                this.pipes.splice(p, 1);
1400            }
1401        }
1402
1403        // Kollision med mark/tak
1404        var groundY = WORLD.height - WORLD.groundHeight;
1405        if (this.player.y + this.crane.hitbox.h / 2 >= groundY) {
1406            this.player.y = groundY - this.crane.hitbox.h / 2;
1407            this.endGame();
1408            return;
1409        }
1410        if (this.player.y + this.crane.hitbox.y < WORLD.ceilingPadding) {
1411            this.player.y = WORLD.ceilingPadding - this.crane.hitbox.y;
1412            this.player.vy = 0;
1413        }
1414
1415        // Kollision med rör
1416        if (this.checkPipeCollision()) {
1417            this.endGame();
1418        }
1419    };
1420
1421    Game.prototype.spawnPipe = function () {
1422        var topY = rand(WORLD.pipeMinTop, WORLD.pipeMaxTop);
1423        this.pipes.push({
1424            x: this.worldWidth + 20,
1425            topY: topY,           // nederkant på övre rör
1426            bottomY: topY + WORLD.pipeGap, // överkant på undre rör
1427            scored: false,
1428        });
1429    };
1430
1431    Game.prototype.checkPipeCollision = function () {
1432        var hb = this.crane.hitbox;
1433        var px = this.player.x + hb.x;
1434        var py = this.player.y + hb.y;
1435        var pw = hb.w;
1436        var ph = hb.h;
1437
1438        for (var i = 0; i < this.pipes.length; i++) {
1439            var pipe = this.pipes[i];
1440            if (pipe.x + WORLD.pipeWidth < px) continue;
1441            if (pipe.x > px + pw) continue;
1442
1443            // Övre rör: y från 0 till pipe.topY
1444            if (py < pipe.topY) return true;
1445            // Undre rör: y från pipe.bottomY till mark
1446            if (py + ph > pipe.bottomY) return true;
1447        }
1448        return false;
1449    };
1450
1451    /* ---- Rendering ---- */
1452
1453    Game.prototype.resize = function () {
1454        var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
1455        var rect = this.canvas.getBoundingClientRect();
1456        var cssW = rect.width || this.worldWidth;
1457        var cssH = rect.height || WORLD.height;
1458
1459        // Sätt internal pixel-storlek = CSS-storlek * DPR
1460        this.canvas.width = Math.round(cssW * dpr);
1461        this.canvas.height = Math.round(cssH * dpr);
1462        this.dpr = dpr;
1463
1464        // Räkna ut ny världsbredd så att skala blir isotropisk
1465        // (scaleX === scaleY). Höjden är fix vid WORLD.height så fysiken
1466        // beter sig identiskt oavsett aspect ratio.
1467        var aspect = cssW / cssH;
1468        var minWidth = 240;   // skydda mot extremt smala viewports
1469        var maxWidth = 2400;  // skydda mot extremt ultrawide
1470        var newWidth = clamp(Math.round(WORLD.height * aspect), minWidth, maxWidth);
1471
1472        // Skala om befintliga objekt-positioner så att de visuellt stannar
1473        // på samma relativa plats när bredden ändras (t.ex. när webbläsaren
1474        // går in/ut ur fullscreen eller användaren roterar mobilen).
1475        var oldWidth = this.worldWidth || WORLD.width;
1476        if (newWidth !== oldWidth) {
1477            var ratio = newWidth / oldWidth;
1478            if (this.player) {
1479                this.player.x *= ratio;
1480            }
1481            if (this.pipes) {
1482                for (var i = 0; i < this.pipes.length; i++) {
1483                    this.pipes[i].x *= ratio;
1484                }
1485            }
1486        }
1487
1488        this.worldWidth = newWidth;
1489
1490        // Räkna ut skala från logiskt WORLD till canvas pixels
1491        this.scaleX = this.canvas.width / this.worldWidth;
1492        this.scaleY = this.canvas.height / WORLD.height;
1493    };
1494
1495    /**
1496     * Räknar fram aktuellt visuellt tillstånd för himmel/sol/måne/stjärnor
1497     * baserat på `this.dayTime`. Alla värden lerpas med smoothstep så
1498     * övergångarna är mjuka.
1499     */
1500    Game.prototype.getSkyState = function () {
1501        var t = this.dayTime;
1502        var phase = t / PHASE_LENGTH;     // 0..4
1503        var idx = Math.floor(phase) % 4;
1504        var nextIdx = (idx + 1) % 4;
1505        var f = smoothstep(phase - Math.floor(phase));
1506
1507        var p0 = SKY_PALETTES[idx];
1508        var p1 = SKY_PALETTES[nextIdx];
1509
1510        var sky = {
1511            top: lerpRgb(p0.top, p1.top, f),
1512            mid: lerpRgb(p0.mid, p1.mid, f),
1513            bot: lerpRgb(p0.bot, p1.bot, f),
1514        };
1515
1516        var a0 = AMBIENT_TINT[idx];
1517        var a1 = AMBIENT_TINT[nextIdx];
1518        var ambient = [
1519            lerp(a0[0], a1[0], f),
1520            lerp(a0[1], a1[1], f),
1521            lerp(a0[2], a1[2], f),
1522            lerp(a0[3], a1[3], f),
1523        ];
1524
1525        // Sol synlig 0–90s med fade-in/out vid kanterna
1526        var sun = null;
1527        if (t < 92) {
1528            var sunFade = 1;
1529            if (t < 4) sunFade = t / 4;
1530            else if (t > 85) sunFade = Math.max(0, 1 - (t - 85) / 7);
1531
1532            var sunT = t / 90;                          // 0..1 över dagen
1533            var sunArc = Math.sin(sunT * Math.PI);      // 0 vid horisont, 1 i zenit
1534            sun = {
1535                x: this.worldWidth * (0.10 + 0.80 * sunT),
1536                y: WORLD.height * 0.55 - sunArc * WORLD.height * 0.45,
1537                radius: 36 + sunArc * 6,
1538                alpha: sunFade,
1539                // Varm orange vid horisonten, gulvit i zenit
1540                color: lerpRgb([255, 130, 70], [255, 240, 170], sunArc),
1541            };
1542        }
1543
1544        // Måne synlig 85–120s, med fade-in/out
1545        var moon = null;
1546        if (t >= 85) {
1547            var moonFade = 1;
1548            if (t < 92) moonFade = (t - 85) / 7;
1549            else if (t > 115) moonFade = Math.max(0, 1 - (t - 115) / 5);
1550
1551            var moonT = clamp((t - 90) / 30, 0, 1);
1552            var moonArc = Math.sin(moonT * Math.PI);
1553            moon = {
1554                x: this.worldWidth * (0.18 + 0.64 * moonT),
1555                y: WORLD.height * 0.42 - moonArc * WORLD.height * 0.28,
1556                radius: 26,
1557                alpha: moonFade,
1558            };
1559        }
1560
1561        // Stjärnor: fade in från sen kväll, full vid natt, ut vid morgon
1562        var starAlpha = 0;
1563        if (t >= 65 && t < 95) starAlpha = (t - 65) / 30;
1564        else if (t >= 95 && t < 115) starAlpha = 1;
1565        else if (t >= 115) starAlpha = Math.max(0, 1 - (t - 115) / 5);
1566
1567        // Stadsljus tänds gradvis från sen kväll, är fullt på vid natt och
1568        // släcks i gryningen. Smoothstep ger en mjuk ramp.
1569        var lightAlpha = 0;
1570        if (t >= 50 && t < 90) {
1571            lightAlpha = smoothstep((t - 50) / 40);
1572        } else if (t >= 90 && t < 115) {
1573            lightAlpha = 1;
1574        } else if (t >= 115 && t < DAY_CYCLE_LENGTH) {
1575            lightAlpha = 1 - smoothstep((t - 115) / 5);
1576        }
1577
1578        return {
1579            sky: sky,
1580            sun: sun,
1581            moon: moon,
1582            starAlpha: starAlpha,
1583            lightAlpha: lightAlpha,
1584            ambient: ambient,
1585        };
1586    };
1587
1588    Game.prototype.render = function () {
1589        var ctx = this.ctx;
1590
1591        // Skala till logiska koordinater
1592        ctx.setTransform(this.scaleX, 0, 0, this.scaleY, 0, 0);
1593
1594        var sky = this.getSkyState();
1595
1596        // 1) Himmel (gradient) – tar dynamiska färger från cykeln
1597        var grad = ctx.createLinearGradient(0, 0, 0, WORLD.height);
1598        grad.addColorStop(0, rgbCss(sky.sky.top));
1599        grad.addColorStop(0.55, rgbCss(sky.sky.mid));
1600        grad.addColorStop(1, rgbCss(sky.sky.bot));
1601        ctx.fillStyle = grad;
1602        ctx.fillRect(0, 0, this.worldWidth, WORLD.height);
1603
1604        // 2) Stjärnor (om synliga) – tilea över hela världsbredden
1605        if (sky.starAlpha > 0.01) {
1606            ctx.globalAlpha = sky.starAlpha;
1607            var sw = this.stars.width;
1608            for (var sx = 0; sx < this.worldWidth; sx += sw) {
1609                ctx.drawImage(this.stars, sx, 0);
1610            }
1611            ctx.globalAlpha = 1;
1612        }
1613
1614        // 3) Sol / MÃ¥ne
1615        if (sky.sun) this.drawSun(sky.sun);
1616        if (sky.moon) this.drawMoon(sky.moon);
1617
1618        // 4) Moln – tonas ned på natten via lägre opacity
1619        var cloudAlpha = 0.85 * (1 - sky.starAlpha * 0.7);
1620        this.drawClouds(cloudAlpha);
1621
1622        // 5) Bakgrundslager (parallax)
1623        for (var i = 0; i < this.bgLayers.length; i++) {
1624            var L = this.bgLayers[i];
1625            this.drawTiledLayer(L.canvas, L.y, this.layerOffsets[i]);
1626        }
1627
1628        // 6) Rör (i världen, ritas innan mark)
1629        this.drawPipes();
1630
1631        // 7) Mark
1632        this.drawGround();
1633
1634        // 8) Spelaren
1635        this.drawCrane();
1636
1637        // 9) Ambient tint – mörknar förgrunden mot kväll/natt.
1638        //    Behåller starka ljus (sol/måne/stjärnor) ljusa medan
1639        //    resten av scenen tonas.
1640        if (sky.ambient[3] > 0.001) {
1641            ctx.fillStyle = rgbaCss(sky.ambient, sky.ambient[3]);
1642            ctx.fillRect(0, 0, this.worldWidth, WORLD.height);
1643        }
1644
1645        // 10) Stadsljus – tänds från skymning och lyser igenom ambient-mörkret.
1646        //     Ritas additivt (lighter) så glow-effekten blir genuint lysande.
1647        if (sky.lightAlpha > 0.005) {
1648            this.drawCityLights(sky.lightAlpha);
1649        }
1650
1651        // 11) HUD ovanpå allt
1652        if (this.state === 'playing' || this.state === 'gameover') {
1653            this.drawHUD();
1654        }
1655    };
1656
1657    Game.prototype.drawCityLights = function (alpha) {
1658        var ctx = this.ctx;
1659        var hasAnyLights = false;
1660        for (var i = 0; i < this.bgLayers.length; i++) {
1661            if (this.bgLayers[i].lights) {
1662                hasAnyLights = true;
1663                break;
1664            }
1665        }
1666        if (!hasAnyLights) return;
1667
1668        // Klipp bort rörens areor så att den additiva ljusritningen inte
1669        // landar på rör-pixlarna. Annars "lyser" stadens fönster och lyktor
1670        // igenom rören och de ser genomskinliga ut. Vi använder even-odd
1671        // fillRule: yttre rektangel = hela världen, inre rektanglar = rören,
1672        // vilket ger ett clip-område = världen utom rören.
1673        ctx.save();
1674        ctx.beginPath();
1675        ctx.rect(0, 0, this.worldWidth, WORLD.height);
1676
1677        if (this.pipes && this.pipes.length > 0) {
1678            var groundY = WORLD.height - WORLD.groundHeight;
1679            var capPad = 6; // rörens "mun" sticker ut 6 px utanför pipeWidth
1680            var clipW = WORLD.pipeWidth + capPad * 2;
1681            for (var k = 0; k < this.pipes.length; k++) {
1682                var p = this.pipes[k];
1683                // Övre rör (0 .. topY)
1684                if (p.topY > 0) {
1685                    ctx.rect(p.x - capPad, 0, clipW, p.topY);
1686                }
1687                // Undre rör (bottomY .. groundY)
1688                if (groundY > p.bottomY) {
1689                    ctx.rect(p.x - capPad, p.bottomY, clipW, groundY - p.bottomY);
1690                }
1691            }
1692        }
1693
1694        ctx.clip('evenodd');
1695
1696        var prevOp = ctx.globalCompositeOperation;
1697        ctx.globalCompositeOperation = 'lighter';
1698
1699        for (var j = 0; j < this.bgLayers.length; j++) {
1700            var L = this.bgLayers[j];
1701            if (!L.lights) continue;
1702
1703            var scale = typeof L.lightAlphaScale === 'number' ? L.lightAlphaScale : 1;
1704            ctx.globalAlpha = Math.min(1, alpha * scale);
1705
1706            var w = L.lights.width;
1707            var startX = -((this.layerOffsets[j]) % w);
1708            for (var x = startX; x < this.worldWidth; x += w) {
1709                ctx.drawImage(L.lights, x, L.y);
1710            }
1711        }
1712
1713        ctx.globalAlpha = 1;
1714        ctx.globalCompositeOperation = prevOp;
1715        ctx.restore();
1716    };
1717
1718    Game.prototype.drawSun = function (sun) {
1719        var ctx = this.ctx;
1720        if (sun.alpha <= 0) return;
1721        ctx.save();
1722        ctx.globalAlpha = sun.alpha;
1723
1724        // Yttre glow
1725        var glow = ctx.createRadialGradient(
1726            sun.x, sun.y, sun.radius * 0.4,
1727            sun.x, sun.y, sun.radius * 2.6
1728        );
1729        glow.addColorStop(0, rgbaCss(sun.color, 0.55));
1730        glow.addColorStop(1, rgbaCss(sun.color, 0));
1731        ctx.fillStyle = glow;
1732        ctx.beginPath();
1733        ctx.arc(sun.x, sun.y, sun.radius * 2.6, 0, Math.PI * 2);
1734        ctx.fill();
1735
1736        // Solskiva
1737        ctx.fillStyle = rgbaCss(sun.color, 0.95);
1738        ctx.beginPath();
1739        ctx.arc(sun.x, sun.y, sun.radius, 0, Math.PI * 2);
1740        ctx.fill();
1741
1742        ctx.restore();
1743    };
1744
1745    Game.prototype.drawMoon = function (moon) {
1746        var ctx = this.ctx;
1747        if (moon.alpha <= 0) return;
1748        ctx.save();
1749        ctx.globalAlpha = moon.alpha;
1750
1751        // Yttre glow
1752        var glow = ctx.createRadialGradient(
1753            moon.x, moon.y, moon.radius * 0.5,
1754            moon.x, moon.y, moon.radius * 2.4
1755        );
1756        glow.addColorStop(0, 'rgba(220, 230, 255, 0.45)');
1757        glow.addColorStop(1, 'rgba(220, 230, 255, 0)');
1758        ctx.fillStyle = glow;
1759        ctx.beginPath();
1760        ctx.arc(moon.x, moon.y, moon.radius * 2.4, 0, Math.PI * 2);
1761        ctx.fill();
1762
1763        // MÃ¥neskiva
1764        ctx.fillStyle = '#f4f0d8';
1765        ctx.beginPath();
1766        ctx.arc(moon.x, moon.y, moon.radius, 0, Math.PI * 2);
1767        ctx.fill();
1768
1769        // Skuggad sida (skapar 3D-form)
1770        ctx.fillStyle = 'rgba(40, 50, 90, 0.18)';
1771        ctx.beginPath();
1772        ctx.arc(moon.x + moon.radius * 0.25, moon.y, moon.radius, 0, Math.PI * 2);
1773        ctx.fill();
1774
1775        // Krater-detaljer
1776        ctx.fillStyle = 'rgba(120, 110, 90, 0.35)';
1777        ctx.beginPath();
1778        ctx.arc(moon.x - moon.radius * 0.3, moon.y - moon.radius * 0.25, moon.radius * 0.15, 0, Math.PI * 2);
1779        ctx.arc(moon.x + moon.radius * 0.1, moon.y + moon.radius * 0.2, moon.radius * 0.1, 0, Math.PI * 2);
1780        ctx.arc(moon.x - moon.radius * 0.05, moon.y + moon.radius * 0.4, moon.radius * 0.08, 0, Math.PI * 2);
1781        ctx.fill();
1782
1783        ctx.restore();
1784    };
1785
1786    Game.prototype.drawClouds = function (alpha) {
1787        var ctx = this.ctx;
1788        var cloudY = [60, 130, 200, 90];
1789        var spacing = 200;
1790        var offset = this.cloudOffset;
1791        var a = typeof alpha === 'number' ? alpha : 0.85;
1792        if (a <= 0.01) return;
1793
1794        for (var i = 0; i < 6; i++) {
1795            var x = ((i * spacing - offset) % (this.worldWidth + spacing) + (this.worldWidth + spacing)) % (this.worldWidth + spacing) - 120;
1796            var y = cloudY[i % cloudY.length];
1797            ctx.globalAlpha = a;
1798            ctx.drawImage(this.cloud, x, y);
1799        }
1800        ctx.globalAlpha = 1;
1801    };
1802
1803    Game.prototype.drawTiledLayer = function (canvas, y, offset) {
1804        var ctx = this.ctx;
1805        var w = canvas.width;
1806        var startX = -((offset) % w);
1807        for (var x = startX; x < this.worldWidth; x += w) {
1808            ctx.drawImage(canvas, x, y);
1809        }
1810    };
1811
1812    Game.prototype.drawPipes = function () {
1813        var ctx = this.ctx;
1814        var groundY = WORLD.height - WORLD.groundHeight;
1815
1816        for (var i = 0; i < this.pipes.length; i++) {
1817            var p = this.pipes[i];
1818
1819            // Pipe-style: vassrör / mossiga staplar
1820            var x = p.x;
1821            var w = WORLD.pipeWidth;
1822
1823            // Övre rör
1824            this.drawReedPipe(x, 0, w, p.topY, true);
1825            // Undre rör
1826            this.drawReedPipe(x, p.bottomY, w, groundY - p.bottomY, false);
1827        }
1828    };
1829
1830    Game.prototype.drawReedPipe = function (x, y, w, h, isTop) {
1831        var ctx = this.ctx;
1832        if (h <= 0) return;
1833
1834        // Gradient för mosstrukturen
1835        var grad = ctx.createLinearGradient(x, 0, x + w, 0);
1836        grad.addColorStop(0, '#2c6b3e');
1837        grad.addColorStop(0.5, '#5fa364');
1838        grad.addColorStop(1, '#2c6b3e');
1839        ctx.fillStyle = grad;
1840        ctx.fillRect(x, y, w, h);
1841
1842        // Mörka kantlinjer
1843        ctx.fillStyle = 'rgba(0,0,0,0.18)';
1844        ctx.fillRect(x, y, 4, h);
1845        ctx.fillRect(x + w - 4, y, 4, h);
1846
1847        // Lite "fjäder"-/vass-textur (horisontella ränder)
1848        ctx.strokeStyle = 'rgba(0,0,0,0.12)';
1849        ctx.lineWidth = 1;
1850        for (var s = 0; s < h; s += 14) {
1851            ctx.beginPath();
1852            ctx.moveTo(x + 6, y + s);
1853            ctx.lineTo(x + w - 6, y + s);
1854            ctx.stroke();
1855        }
1856
1857        // Topp-/bottenkrans (rörets "mun")
1858        var capH = 18;
1859        var capY = isTop ? y + h - capH : y;
1860        var capGrad = ctx.createLinearGradient(x - 6, 0, x + w + 6, 0);
1861        capGrad.addColorStop(0, '#234f30');
1862        capGrad.addColorStop(0.5, '#6db86f');
1863        capGrad.addColorStop(1, '#234f30');
1864        ctx.fillStyle = capGrad;
1865        ctx.fillRect(x - 6, capY, w + 12, capH);
1866
1867        ctx.strokeStyle = 'rgba(0,0,0,0.35)';
1868        ctx.lineWidth = 1;
1869        ctx.strokeRect(x - 6, capY, w + 12, capH);
1870    };
1871
1872    Game.prototype.drawGround = function () {
1873        var ctx = this.ctx;
1874        var gy = WORLD.height - WORLD.groundHeight;
1875
1876        // Mossbas
1877        ctx.fillStyle = '#6fa15a';
1878        ctx.fillRect(0, gy, this.worldWidth, 18);
1879
1880        // Jord under
1881        var dirtGrad = ctx.createLinearGradient(0, gy + 18, 0, WORLD.height);
1882        dirtGrad.addColorStop(0, '#a37247');
1883        dirtGrad.addColorStop(1, '#6b4a2d');
1884        ctx.fillStyle = dirtGrad;
1885        ctx.fillRect(0, gy + 18, this.worldWidth, WORLD.groundHeight - 18);
1886
1887        // Gräsdetaljer som rör sig
1888        ctx.fillStyle = '#7eb86b';
1889        for (var x = -48 + this.groundOffset; x < this.worldWidth; x += 48) {
1890            ctx.beginPath();
1891            ctx.moveTo(x, gy);
1892            ctx.lineTo(x + 6, gy - 6);
1893            ctx.lineTo(x + 12, gy);
1894            ctx.closePath();
1895            ctx.fill();
1896        }
1897
1898        // Små stenar
1899        ctx.fillStyle = 'rgba(0,0,0,0.25)';
1900        for (var sx = -32 + this.groundOffset; sx < this.worldWidth; sx += 96) {
1901            ctx.beginPath();
1902            ctx.ellipse(sx, gy + 30, 6, 3, 0, 0, Math.PI * 2);
1903            ctx.fill();
1904        }
1905    };
1906
1907    Game.prototype.drawCrane = function () {
1908        var ctx = this.ctx;
1909        var p = this.player;
1910        var s = this.crane;
1911
1912        // Vingvinkel: sinusoidalt baserad på fasen.
1913        // Ner-stroke är "kraftigare" (asymmetrisk), upp-stroke mjukare.
1914        var raw = Math.sin(p.wingPhase);
1915        var wingAngle = raw >= 0
1916            ? raw * 1.05            // down-stroke: upp till ~+1.05 rad (~60°)
1917            : raw * 0.75;           // up-stroke: ner till ~-0.75 rad (~-43°)
1918
1919        ctx.save();
1920        ctx.translate(p.x, p.y);
1921        ctx.rotate(p.rotation);
1922
1923        // 1) Ben + sten (bakom kroppen)
1924        drawCraneLegs(ctx, p.stoneSwing, s.hip);
1925
1926        // 2) Kroppen (statisk sprite)
1927        ctx.drawImage(s.canvas, -58, -45);
1928
1929        // 3) Vingen ovanpå – animerad
1930        drawCraneWing(ctx, wingAngle, s.shoulder);
1931
1932        // Debug hitbox (avkommentera vid behov)
1933        // ctx.strokeStyle = 'red';
1934        // ctx.strokeRect(s.hitbox.x, s.hitbox.y, s.hitbox.w, s.hitbox.h);
1935
1936        ctx.restore();
1937    };
1938
1939    Game.prototype.drawHUD = function () {
1940        var ctx = this.ctx;
1941        ctx.save();
1942        ctx.font = 'bold 42px -apple-system, "Segoe UI", Roboto, sans-serif';
1943        ctx.textAlign = 'center';
1944        ctx.textBaseline = 'top';
1945        ctx.lineWidth = 5;
1946        ctx.strokeStyle = 'rgba(0,0,0,0.55)';
1947        ctx.fillStyle = '#fff';
1948        var text = String(this.score);
1949        ctx.strokeText(text, this.worldWidth / 2, 26);
1950        ctx.fillText(text, this.worldWidth / 2, 26);
1951
1952        if (this.best > 0) {
1953            ctx.font = '600 14px -apple-system, "Segoe UI", Roboto, sans-serif';
1954            ctx.fillStyle = 'rgba(255,255,255,0.9)';
1955            ctx.strokeStyle = 'rgba(0,0,0,0.5)';
1956            ctx.lineWidth = 3;
1957            var bestText = (I18N.best || 'Best') + ': ' + this.best;
1958            ctx.strokeText(bestText, this.worldWidth / 2, 72);
1959            ctx.fillText(bestText, this.worldWidth / 2, 72);
1960        }
1961        ctx.restore();
1962    };
1963
1964    /* ---- REST / highscores ---- */
1965
1966    Game.prototype.submitScore = function () {
1967        var self = this;
1968        var name = (this.input.value || '').trim();
1969
1970        if (!name) {
1971            this.setMessage(I18N.enterName || 'Ange ett namn.', 'error');
1972            this.input.focus();
1973            return;
1974        }
1975        if (!CFG.restUrl) {
1976            this.setMessage(I18N.saveError || 'Kunde inte spara.', 'error');
1977            return;
1978        }
1979        if (this.lastSubmittedScore <= 0) {
1980            this.setMessage(I18N.saveError || 'Inget att spara.', 'error');
1981            return;
1982        }
1983
1984        this.setMessage(I18N.saving || 'Sparar...', '');
1985        var submitBtn = this.form.querySelector('button[type="submit"]');
1986        if (submitBtn) submitBtn.disabled = true;
1987
1988        fetch(CFG.restUrl, {
1989            method: 'POST',
1990            credentials: 'same-origin',
1991            headers: {
1992                'Content-Type': 'application/json',
1993                'X-WP-Nonce': CFG.nonce || '',
1994            },
1995            body: JSON.stringify({
1996                name: name,
1997                score: this.lastSubmittedScore,
1998            }),
1999        })
2000            .then(function (res) {
2001                return res.json().then(function (data) {
2002                    return { ok: res.ok, status: res.status, data: data };
2003                });
2004            })
2005            .then(function (resp) {
2006                if (submitBtn) submitBtn.disabled = false;
2007                if (!resp.ok || !resp.data || !resp.data.success) {
2008                    var msg = (resp.data && resp.data.message) ||
2009                        I18N.saveError ||
2010                        'Kunde inte spara.';
2011                    self.setMessage(msg, 'error');
2012                    return;
2013                }
2014                self.lastSubmittedId = resp.data.id;
2015                var rankMsg = (I18N.saved || 'Sparat!') +
2016                    ' ' +
2017                    (I18N.rank || 'Placering') +
2018                    ': #' +
2019                    resp.data.rank;
2020                self.setMessage(rankMsg, 'success');
2021                self.renderScores(resp.data.scores || [], resp.data.id);
2022            })
2023            .catch(function () {
2024                if (submitBtn) submitBtn.disabled = false;
2025                self.setMessage(I18N.saveError || 'Kunde inte spara.', 'error');
2026            });
2027    };
2028
2029    Game.prototype.fetchHighscores = function () {
2030        var self = this;
2031        if (!CFG.restUrl) return;
2032        fetch(CFG.restUrl + '?limit=10', {
2033            credentials: 'same-origin',
2034        })
2035            .then(function (res) {
2036                return res.ok ? res.json() : null;
2037            })
2038            .then(function (data) {
2039                if (data && data.success && Array.isArray(data.scores)) {
2040                    self.renderScores(data.scores);
2041                }
2042            })
2043            .catch(function () {
2044                /* tyst */
2045            });
2046    };
2047
2048    Game.prototype.renderScores = function (scores, highlightId) {
2049        if (!this.scoresList) return;
2050        this.scoresList.innerHTML = '';
2051        if (!scores.length) {
2052            var li = document.createElement('li');
2053            li.innerHTML = '<span class="tt-empty">' +
2054                escapeHtml(I18N.noScores || 'Inga resultat ännu – var först!') +
2055                '</span>';
2056            this.scoresList.appendChild(li);
2057            return;
2058        }
2059        for (var i = 0; i < scores.length; i++) {
2060            var s = scores[i];
2061            var li2 = document.createElement('li');
2062            if (highlightId && s.id === highlightId) {
2063                li2.setAttribute('data-self', 'true');
2064            }
2065            li2.innerHTML =
2066                '<span class="tt-name">' +
2067                escapeHtml(s.name) +
2068                '</span>' +
2069                '<span class="tt-score">' +
2070                escapeHtml(String(s.score)) +
2071                '</span>';
2072            this.scoresList.appendChild(li2);
2073        }
2074    };
2075
2076    Game.prototype.setMessage = function (text, state) {
2077        if (!this.messageEl) return;
2078        this.messageEl.hidden = !text;
2079        this.messageEl.textContent = text || '';
2080        if (state) {
2081            this.messageEl.setAttribute('data-state', state);
2082        } else {
2083            this.messageEl.removeAttribute('data-state');
2084        }
2085    };
2086
2087    /* ---------------------------------------------------------------------- *
2088     *  Bootstrap – launcher + helskärm
2089     *
2090     *  Spelet initieras lat (lazy) först när användaren trycker på
2091     *  "Spela Taniga Tranan". Overlayen flyttas till <body> så att inga
2092     *  transform/filter på temats wrappers bryter position: fixed.
2093     * ---------------------------------------------------------------------- */
2094
2095    function ensureFullscreenInBody(fullscreen) {
2096        if (fullscreen.parentNode !== document.body) {
2097            document.body.appendChild(fullscreen);
2098        }
2099    }
2100
2101    function tryRequestFullscreen(el) {
2102        var req =
2103            el.requestFullscreen ||
2104            el.webkitRequestFullscreen ||
2105            el.msRequestFullscreen;
2106        if (typeof req === 'function') {
2107            try {
2108                var p = req.call(el);
2109                if (p && typeof p.catch === 'function') {
2110                    p.catch(function () { /* tyst – t.ex. iOS Safari saknar stöd */ });
2111                }
2112            } catch (e) {
2113                /* ignore */
2114            }
2115        }
2116    }
2117
2118    function tryExitFullscreen() {
2119        var fsEl =
2120            document.fullscreenElement ||
2121            document.webkitFullscreenElement ||
2122            document.msFullscreenElement;
2123        if (!fsEl) return;
2124        var exit =
2125            document.exitFullscreen ||
2126            document.webkitExitFullscreen ||
2127            document.msExitFullscreen;
2128        if (typeof exit === 'function') {
2129            try {
2130                var p = exit.call(document);
2131                if (p && typeof p.catch === 'function') {
2132                    p.catch(function () {});
2133                }
2134            } catch (e) {
2135                /* ignore */
2136            }
2137        }
2138    }
2139
2140    function setupInstance(instance) {
2141        if (instance.dataset.tanigaInit) return;
2142        instance.dataset.tanigaInit = '1';
2143
2144        var openBtn = instance.querySelector('[data-action="open-taniga"]');
2145        var fullscreen = instance.querySelector('[data-fullscreen]');
2146        if (!openBtn || !fullscreen) return;
2147
2148        var closeBtn = fullscreen.querySelector('[data-action="close-taniga"]');
2149        var muteBtn = fullscreen.querySelector('[data-action="toggle-mute"]');
2150        var gameRoot = fullscreen.querySelector('.taniga-tranan');
2151        if (!closeBtn || !gameRoot) return;
2152
2153        if (muteBtn) {
2154            sound.updateMuteButton(muteBtn);
2155            muteBtn.addEventListener('click', function () {
2156                sound.toggleMute();
2157                sound.updateMuteButton(muteBtn);
2158            });
2159        }
2160
2161        var game = null;
2162        var lastFocused = null;
2163
2164        function open() {
2165            sound.unlock();
2166            lastFocused = document.activeElement;
2167            ensureFullscreenInBody(fullscreen);
2168            fullscreen.hidden = false;
2169            document.body.classList.add('taniga-tranan-open');
2170
2171            if (!game) {
2172                game = new Game(gameRoot);
2173            } else {
2174                game.resumeLoop();
2175            }
2176
2177            // Vänta tills layouten har räknat fram CSS-storleken innan vi
2178            // mappar canvasens pixel-buffer.
2179            requestAnimationFrame(function () {
2180                game.resize();
2181                requestAnimationFrame(function () { game.resize(); });
2182            });
2183
2184            tryRequestFullscreen(fullscreen);
2185
2186            sound.startAmbience();
2187
2188            // Fokusera stäng-knappen för tangentbordstillgänglighet.
2189            setTimeout(function () { closeBtn.focus(); }, 0);
2190        }
2191
2192        function close() {
2193            sound.stopAmbience();
2194            fullscreen.hidden = true;
2195            document.body.classList.remove('taniga-tranan-open');
2196            tryExitFullscreen();
2197
2198            if (game) {
2199                game.running = false;
2200            }
2201
2202            if (lastFocused && typeof lastFocused.focus === 'function') {
2203                try { lastFocused.focus(); } catch (e) { /* ignore */ }
2204            } else {
2205                openBtn.focus();
2206            }
2207        }
2208
2209        openBtn.addEventListener('click', function () {
2210            sound.unlock();
2211            sound.ui();
2212            open();
2213        });
2214        closeBtn.addEventListener('click', function () {
2215            sound.ui();
2216            close();
2217        });
2218
2219        document.addEventListener('keydown', function (ev) {
2220            if (ev.key === 'Escape' && !fullscreen.hidden) {
2221                ev.preventDefault();
2222                close();
2223            }
2224        });
2225
2226        // När webbläsarens helskärm växlar (t.ex. användaren trycker F11 eller
2227        // ESC ut ur browser-fullscreen) behöver canvas räknas om.
2228        function onFsChange() {
2229            if (game) {
2230                requestAnimationFrame(function () { game.resize(); });
2231            }
2232        }
2233        document.addEventListener('fullscreenchange', onFsChange);
2234        document.addEventListener('webkitfullscreenchange', onFsChange);
2235    }
2236
2237    function init() {
2238        var instances = document.querySelectorAll('[data-taniga-instance]');
2239        for (var i = 0; i < instances.length; i++) {
2240            setupInstance(instances[i]);
2241        }
2242    }
2243
2244    if (document.readyState === 'loading') {
2245        document.addEventListener('DOMContentLoaded', init);
2246    } else {
2247        init();
2248    }
2249})();

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.