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 '&': '&', 73 '<': '<', 74 '>': '>', 75 '"': '"', 76 "'": ''', 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.