degnekci-simulation / render.js
atasoglu's picture
Claude Opus 5.5
Değnekçi Simulation v0.2.0
5fd40a4
Raw History Blame Contribute Delete
11.9 kB
// 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);
const dpr = Math.min(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);
}
}
}