// Canvas çizimi (2.5D, art.js'teki istiflenmiş katmanlarla). Durağan sahne seviye başına bir kez // ekran dışı canvas'lara çizilir: bg oyuncunun arkasında kalanlar (üst sıra), fg önünde kalanlar // (alttaki tır, iki sıralı dünyada alt sıradaki arabalar ve alt kaldırım süsleri). function mulberry(a) { return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const CONFETTI = ["#ffc93c", "#5fd3a5", "#e2483d", "#2f6fb2", "#f28fb0", "#fff6d6"]; const DEGNEKCI_Y = 56; class Renderer { constructor(canvas) { this.cv = canvas; this.ctx = canvas.getContext("2d"); this.bg = document.createElement("canvas"); this.fg = document.createElement("canvas"); this.scale = 1; this.sim = null; this.systemReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; this.reduce = this.systemReduce; // ayarlardan da kapatılabilir this.fxState = { shake: -1, flash: -1, park: -1 }; this.particles = []; this.bubble = null; this.gesture = null; // değnekçinin son komuta göre hareketi this.dust = []; this.stickHit = false; this.last = performance.now(); } // Canvas'ı kutuya en-boy oranını koruyarak sığdırır fit(box, world) { const s = Math.min(box.width / world.W, box.height / world.H); // telefonda 1.5 yeter: piksel sayısı neredeyse yarıya iner, fark gözle seçilmez const dpr = Math.min(window.matchMedia("(pointer: coarse)").matches ? 1.5 : 2, window.devicePixelRatio || 1); this.cv.style.width = `${Math.floor(world.W * s)}px`; this.cv.style.height = `${Math.floor(world.H * s)}px`; this.cv.width = Math.floor(world.W * s * dpr); this.cv.height = Math.floor(world.H * s * dpr); const scale = s * dpr; if (scale !== this.scale) { this.scale = scale; if (this.sim) this.bake(); // ölçek değişince keskin kalsın } } setLevel(sim) { this.sim = sim; this.seed = Math.floor(Math.random() * 1e9); this.particles = []; this.bubble = null; this.fxState = { shake: -1, flash: -1, park: -1 }; this.bake(); } targetX() { const s = this.sim.spots[this.sim.target]; return s.x + s.w / 2; } // hedef alt sıradaysa değnekçi alt kaldırımda durur bottomTarget() { return this.sim.spots[this.sim.target].row === "bottom"; } degnekciY() { return this.bottomTarget() ? this.sim.bottomCurb + 22 : DEGNEKCI_Y; } bake() { const sim = this.sim, { W, H, CURB } = sim.world, rng = mulberry(this.seed); for (const c of [this.bg, this.fg]) { c.width = Math.ceil(W * this.scale); c.height = Math.ceil(H * this.scale); } const g = this.bg.getContext("2d"); g.setTransform(this.scale, 0, 0, this.scale, 0, 0); drawAsphalt(g, 0, 0, W, H, rng); drawCobbles(g, 0, 0, W, CURB, rng); drawCurbEdge(g, 0, CURB, W); const bc = sim.bottomCurb, two = sim.world.rows === 2; if (two) { // alt kaldırım: yüzü yoldan öte baktığı için kenarı yalnız açık bir şerit olarak görünür drawCobbles(g, 0, bc - 4, W, H - bc + 4, rng); g.fillStyle = "#e2d6bf"; g.fillRect(0, bc - 5, W, 3); } const t = sim.spots[sim.target]; g.fillStyle = "rgba(95, 211, 165, 0.16)"; g.beginPath(); g.roundRect(t.x + 5, t.y + 8, t.w - 10, t.h - 14, 10); g.fill(); g.strokeStyle = PAL.target; g.lineWidth = 3; g.setLineDash([10, 7]); g.stroke(); g.setLineDash([]); g.fillStyle = PAL.line; const last = sim.spots[sim.spots.length - 1]; if (sim.level.kind === "paralel") { // kaldırım boyu cepler: sınırlarda kısa T işaretleri, dış kenarda çizgi for (const s of [...sim.spots, { x: last.x + last.w }]) rrect(g, s.x - 2, CURB + 4, 4, 14, 2); rrect(g, sim.spots[0].x, CURB + sim.level.spotD - 2, last.x + last.w - sim.spots[0].x, 4, 2); } else { // çizgiler kaldırımdan başlar: üst sırada yukarıdan, alt sırada aşağıdan for (const row of ["top", "bottom"]) { const rs = sim.spots.filter((s) => s.row === row), off = row === "top" ? 4 : 0; for (const s of [...rs, ...rs.slice(-1).map((s) => ({ ...s, x: s.x + s.w }))]) rrect(g, s.x - 2, s.y + off, 4, s.h - 4, 2); } } if (sim.level.kind === "geri") { // cebe boyalı ok: burun yola baksın (alt sırada ters) g.save(); g.translate(t.x + t.w / 2, t.y + t.h / 2); if (t.row === "bottom") g.scale(1, -1); g.fillStyle = "rgba(95, 211, 165, 0.55)"; g.beginPath(); g.moveTo(0, 26); g.lineTo(18, 6); g.lineTo(7, 6); g.lineTo(7, -24); g.lineTo(-7, -24); g.lineTo(-7, 6); g.lineTo(-18, 6); g.closePath(); g.fill(); g.restore(); } // kaldırım süsleri; değnekçinin durduğu yer boş kalır const tx = this.targetX(), props = [drawTree, drawCayOcagi, drawCat, drawSimitci, drawTree, drawCat]; let k = Math.floor(rng() * props.length); const sidewalk = (c, y0, skip) => { for (let x = 40 + rng() * 30; x < W - 30; x += 95 + rng() * 50) { if (skip && Math.abs(x - tx) < 75) continue; const fn = props[k++ % props.length]; if (fn === drawCat) drawCat(c, x, y0 + 40 + rng() * 10, rng() < 0.5 ? "#f0a35a" : "#d9d4c7"); else if (fn === drawTree) drawTree(c, x, y0 + 44); else fn(c, x, y0 + 50); } }; sidewalk(g, 0, !this.bottomTarget()); const f = this.fg.getContext("2d"); f.setTransform(this.scale, 0, 0, this.scale, 0, 0); const rowEnd = CURB + sim.level.spotD + 30; for (const v of [...sim.parked].sort((a, b) => a.y - b.y)) drawVehicle(v.y < rowEnd ? g : f, v); if (two) sidewalk(f, bc, this.bottomTarget()); // alt kaldırım süsleri alt sıradaki arabaların önünde } // Değnekçinin balonu oyuncunun komutunu tekrarlar, eli de komuta göre hareket eder say(text, cmd) { const now = performance.now(); this.bubble = { text, t: now }; if (cmd) this.gesture = { cmd, t: now / 1000 }; } // Değnekçinin duruşu: varsayılan hâli değneği yere vura vura "gel gel" demek degnekciPose(t, parking, car) { const tap = (period, t0 = 0) => { const p = (((t - t0) / period) % 1 + 1) % 1, up = 0.15, hit = 2.6; if (p < 0.3) return up + (hit - up) * (p / 0.3) ** 2; // yerçekimiyle hızlanarak iner if (p < 0.42) return hit; // yerde bir an kalır const q = (p - 0.42) / 0.58; return hit - (hit - up) * (1 - (1 - q) ** 2); // yavaşça kalkar }; if (parking) return { phi: 0.5 + 0.5 * Math.sin(t * 12), side: 1, other: "wave", hop: Math.abs(Math.sin(t * 14)) * 5, t }; const gs = this.gesture && t - this.gesture.t < 2.2 ? this.gesture : null; const cmd = gs ? gs.cmd : "", t0 = gs ? gs.t : 0; if (cmd === "dur") return { phi: 0.05, side: 1, other: "palm", t }; if (cmd === "sola" || cmd === "sağa" || cmd === "kır") { const side = cmd === "sola" ? -1 : cmd === "sağa" ? 1 : car.lastDir; return { phi: 1.3 + 0.4 * Math.sin((t - t0) * 9), side, t }; // o yöne savurur } return { phi: tap(cmd === "geri" ? 0.55 : 0.9, t0), side: 1, t }; } fx(type) { const now = performance.now(), c = this.sim.car; if (type === "crash") { this.fxState.shake = now; this.fxState.flash = now; } else if (type === "park") { this.fxState.park = now; for (let i = 0; i < (this.reduce ? 0 : 46); i++) { const a = Math.random() * Math.PI * 2, sp = 40 + Math.random() * 90; this.particles.push({ x: c.x, y: c.y, z: 20, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.7, vz: 120 + Math.random() * 140, rot: Math.random() * 6, vr: (Math.random() - 0.5) * 14, col: CONFETTI[i % CONFETTI.length], life: 1.4, }); } } } // değnekçi ve değneğin kaldırdığı toz drawMan(g, t, dt, parking) { const dx = this.targetX(), dy = this.degnekciY(), pose = drawDegnekci(g, dx, dy, this.degnekciPose(t, parking, this.sim.car)); if (pose.hit && !this.stickHit) { // değnek yere vurdu: küçük toz bulutu for (let i = 0; i < (this.reduce ? 2 : 5); i++) { const a = Math.random() * Math.PI * 2; this.dust.push({ x: dx + pose.tip.x, y: dy + pose.tip.y, vx: Math.cos(a) * 14, vy: Math.sin(a) * 6 - 4, life: 0.4 }); } } this.stickHit = pose.hit; this.dust = this.dust.filter((p) => (p.life -= dt) > 0); for (const p of this.dust) { p.x += p.vx * dt; p.y += p.vy * dt; g.fillStyle = `rgba(226, 214, 191, ${p.life * 1.6})`; disc(g, p.x, p.y, 2 + (0.4 - p.life) * 6); } } draw(sim) { const now = performance.now(), dt = Math.min(0.05, (now - this.last) / 1000), t = now / 1000; this.last = now; const g = this.ctx, { W, H } = sim.world, fx = this.fxState; let ox = 0, oy = 0; const sh = (now - fx.shake) / 180; if (fx.shake >= 0 && sh < 1 && !this.reduce) { const a = 5 * (1 - sh); ox = (Math.random() - 0.5) * 2 * a; oy = (Math.random() - 0.5) * 2 * a; } g.setTransform(1, 0, 0, 1, 0, 0); g.fillStyle = PAL.asphalt; g.fillRect(0, 0, this.cv.width, this.cv.height); g.drawImage(this.bg, ox * this.scale, oy * this.scale); g.setTransform(this.scale, 0, 0, this.scale, ox * this.scale, oy * this.scale); // hedef cep nefes alır const s = sim.spots[sim.target]; g.fillStyle = `rgba(95, 211, 165, ${0.1 + 0.1 * Math.sin(t * 4)})`; g.beginPath(); g.roundRect(s.x + 5, s.y + 8, s.w - 10, s.h - 14, 10); g.fill(); const pt = (now - fx.park) / 1000, parking = fx.park >= 0 && pt < 1.6; // değnekçi üst kaldırımda araçların arkasında, alt kaldırımda önünde kalır const front = this.bottomTarget(); if (!front) this.drawMan(g, t, dt, parking); // yoldaki araçlar (oyuncu + trafik) y'ye göre sıralanır: aşağıdaki öndedir const c = sim.car; const squash = parking && !this.reduce && pt < 0.8 ? 1 + 0.35 * Math.sin(pt * Math.PI * 5) * Math.exp(-pt * 4) : 1; for (const v of [c, ...sim.traffic].sort((a, b) => a.y - b.y)) { if (v !== c) { drawVehicle(g, v, { brake: v.braking }); continue; } // oyuncu aracı: altında hafif bir hale, park edince zıplama g.save(); g.translate(c.x, c.y); g.rotate(c.ang); g.strokeStyle = `rgba(255, 236, 160, ${0.35 + 0.15 * Math.sin(t * 5)})`; g.lineWidth = 3; g.beginPath(); g.roundRect(-c.L / 2 - 6, -c.W / 2 - 6, c.L + 12, c.W + 12, 14); g.stroke(); g.restore(); drawVehicle(g, c, { steer: c.steer, brake: c.stopped, reverse: c.gear < 0 && !c.stopped, squash }); } g.setTransform(1, 0, 0, 1, 0, 0); g.drawImage(this.fg, ox * this.scale, oy * this.scale); g.setTransform(this.scale, 0, 0, this.scale, ox * this.scale, oy * this.scale); if (front) this.drawMan(g, t, dt, parking); // konfeti this.particles = this.particles.filter((p) => (p.life -= dt) > 0); for (const p of this.particles) { p.vz -= 420 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.z = Math.max(0, p.z + p.vz * dt); if (p.z === 0) p.vx = p.vy = p.vr = 0; p.rot += p.vr * dt; g.save(); g.globalAlpha = Math.min(1, p.life * 2); g.translate(p.x, p.y - p.z); g.rotate(p.rot); g.fillStyle = p.col; g.fillRect(-3, -2, 6, 4); g.restore(); } if (this.bubble) { const age = (now - this.bubble.t) / 1000; if (age > 1.8) this.bubble = null; else drawBubble(g, this.targetX() + 6, this.degnekciY() - 40, this.bubble.text, W, Math.min(1, (1.8 - age) / 0.3)); } const fl = (now - fx.flash) / 260; if (fx.flash >= 0 && fl < 1) { g.fillStyle = `rgba(226, 72, 61, ${0.22 * (1 - fl)})`; g.fillRect(-10, -10, W + 20, H + 20); } } }