commit 2024ea48b5f45c663c520b268ba6d590d44bf3f0 Author: Primal Rampage Bot Date: Sun Aug 23 04:26:58 2026 +0000 Primal Rampage — prehistoric survival wave shooter Vanilla JS + Canvas 2D, zero dependencies. Gameplay: campaign waves vs 12 dino species + 3 bosses, arena mode, 7 weapons, hero talents, pets, ult/bombs, card roguelite picks. World: day/night cycle, weather fronts (rain+lightning), Blood Moon weekends, 3 act biomes (jungle -> swamp -> ashlands with lava veins). Meta: gems economy, upgrade tree, quests, bestiary, achievements, shop (weapons/heroes/skins incl. premium), revive, ad-cap system, endless scaling past wave 25, live-painted shop/bestiary icons, pro main menu, auto-aim toggle (F). Headless smoke suite included. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d9b168 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.dsh-notes.json +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..853edcd --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# PRIMAL RAMPAGE — Stone Age Survival + +A from-scratch, dependency-free clone of the *Unfair Shooter*-style browser +wave shooter, re-themed as **prehistoric tribe survival vs dinosaurs**: +endless campaign waves (plus an **Endless Mode** past wave 25), alpha-dinosaur +bosses every 5th wave with Jurassic-style footstep tremors, roguelite card +picks between waves, an ON FIRE heat/combo system, a full meta-progression +layer (bestiary, permanent upgrades, daily quests, achievements, pets, +hard currency), and an Arena deathmatch vs rival tribesmen bots. + +Everything is original: code, **procedurally drawn canvas art** (cel-outlined +tribal hero with recoil + muzzle flash, raptors with animated tails & jaws, +triceratops, dilophosaurus frill-flare spitters, flapping pteranodons, a +sail-backed Spinosaurus boss, a T-Rex with hinged jaw…), synthesized +WebAudio SFX/music/wind ambience, and a living jungle arena. No assets were +taken from any existing game. + +## Run + +```bash +cd jsshooter +python3 -m http.server 8899 +# open http://127.0.0.1:8899/ +``` + +No build step — plain script tags. + +## Controls + +| Action | Keyboard / Mouse | Touch | +|---|---|---| +| Move | WASD / Arrows | left stick | +| Aim + fire | mouse (hold LMB or Space; auto-fire on by default) | right stick + 🔥 | +| Ultimate | R | ★ | +| Bomb | Q | 💣 | +| Pause | P / Esc | ⏸ | + +## The cast + +- **Act biomes**: waves 1–9 in **The Green Jungle**, 10–18 descend into + **The Murk Swamp** (creeping fog banks, two extra tar pits), 19+ burn + across **The Ashlands** (pulsing lava veins that sear anyone standing on + them). Ground palette, grass and rim tint all shift per biome. +- **Beasts**: Velociraptor, Compys, Dilophosaurus (venom spit — its frill + flares before firing!), Triceratops, Stegosaurus (plate rows + thagomizer), + Pachycephalosaurus (telegraphed `!` head-butt dash), Pteranodon (splits + into chicks), Alpha Raptor. Kills leave blood decals; runners kick dust; + the player leaves alternating footprints. +- **Alphas (boss waves)**: REX THE ELDER (charges, calls raptors) → SPINO OF + THE REEDS (sail, venom barrages) → GIGA BROODMOTHER (splits into two + juveniles). Every stride shakes the screen — harder up close — with dust + rings and a deep stomp thud. +- **Arsenal**: Slingshot → Bone Blowpipe → Spear Burst → Obsidian Lance → + Fire Bomb Thrower. +- **Heroes**: Raider, Trail Runner, Bear-Clan Brute, Spirit Hunter; war-paint + skins incl. premium gem tier (Ashfang Chief, Jade Serpent, Solaris). + +## Living arena & atmosphere + +Campfire hub with flickering light at the world centre · tar pits that slow +anything crossing them (55%) · giant buried T-Rex skull + ribcage landmarks · +totem stakes · a 60s **day/night cycle** (night floods the valley in +darkness, punched through by warm light around every flame; beasts move +8% faster under moonlight) · **tropical rain fronts** with lightning flashes +and rolling thunder · **Blood Moon weekend live-op** (real-calendar Sat/Sun: +amber ×2, +25% herd budget, red cast + HUD badge) · drifting cloud shadows · circling vulture shadow · floating +ash motes · wind ambience with distant beast calls · painted dusk menu scene +(volcano, brachiosaurus, raptor silhouettes). Low quality setting disables +the air layers for weak devices. + +## Meta progression (commercialization layer) + +- **💪 Permanent upgrades** — six amber-bought nodes applied to every run: + Wolf-Heart (+HP), Viper Fangs (+dmg%), Wind Heels (+speed%), + Young Sun (+ult rate), Vulture Eye (+amber%), Shaman Pouch (+start bombs). +- **📖 Bestiary** — 12 species entries with bilingual lore; completing one + pays Ngọn Lửa (🔥 hard currency). +- **📜 Daily quests** — 3 of 6 picked deterministically per date, login + streak counter, claim rewards (🔥 + $). +- **🏆 Achievements** — ten unlock conditions, each grants 🔥. +- **🥚 Pet raptor** — bosses may drop an egg; hatch names a companion that + follows you and snipes pebbles; persists across runs. +- **🔥 Ngọn Lửa economy + rewarded-ad stub** — post-death “watch ad” bonus + (5s fake countdown, capped 3/day); swap in a real H5 ads SDK at + `setupAdButton()` in `js/ui.js`. Server leaderboards/accounts plug in via + `US.net` hooks noted in `js/save.js`. +- **🎓 First-run tutorial** — five skippable steps; settings add screen-shake + amount slider and effects quality toggle. + +## HUD + +Professional panel layout: vitals card (HP gradient bar + HEAT/ON FIRE + +weapon chip + bomb icons), wave banner with pill remaining-count, full-width +boss bar with name/percent, economy card (amber + score) with pulsing combo +pill, ult dial with % readout and ready flare, danger vignette under 30% HP. + +## Layout + +``` +index.html shell + DOM UI overlays (menus, quests, bestiary, tutorial) +style.css stone-age jungle theme + meta UI bits +js/i18n.js EN/VI strings +js/save.js profile v2: gems/upgrades/bestiary/quests/achievements/pet +js/util.js helpers +js/audio.js synth SFX + music loop + wind ambience + stomps +js/art.js procedural painters: hero, dinos, ground props, sky, menus +js/data.js weapons/heroes/skins/beasts/alphas/cards/upgrades/bestiary… +js/entities.js player/enemy/bullet/pickup/particles +js/game.js engine: campaign+arena+endless, On Fire, HUD, meta hooks +js/ui.js menus/shop tabs/cards/overlays/tutorial/ad stub +js/main.js input (keyboard/mouse/touch sticks) + bootstrap +``` + +## Testing + +`/tmp/smoke.js` is a headless harness (DOM/canvas stubs incl. gradient +factories, pinned-target sims) that boots the real modules and simulates +campaign, boss waves, cards, shop, arena, plus the meta layer: + +```bash +node /tmp/smoke.js # ALL SMOKE TESTS PASSED +``` + +Publishing: `cloudflared tunnel --url http://127.0.0.1:8899 --no-autoupdate` +(quick tunnels are ephemeral — restart to get a fresh URL). diff --git a/index.html b/index.html new file mode 100644 index 0000000..cba52b0 --- /dev/null +++ b/index.html @@ -0,0 +1,256 @@ + + + + + + + Primal Rampage — Stone Age Survival + + + + + + +
+ + +
+ + + +
+ + +
+

+
+ + + +
+
+ + + + +
+
+
+
+ + +
+

📜

+
+ + +
+
+

+
+
+ + +
+

📖

+
+ + +
+
+
+ + +
+

+
+ +
+
+
+
+
+
+
+
+
+ + + +
+
+ + +
+ +
+ + +
+
+ + +
+

+
+ # +
+
+
+
+ + +
+

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+
+ + +
+

+
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/js/art.js b/js/art.js new file mode 100644 index 0000000..86fb4a1 --- /dev/null +++ b/js/art.js @@ -0,0 +1,1929 @@ +/* PRIMAL RAMPAGE — procedural prehistoric art. + Every figure is drawn top-down with canvas paths: a tribal human hero, + dinosaurs with animated tails/jaws/wings/legs, and a jungle arena. */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + + var SKIN = '#c99862'; // base skin tone + var FUR = '#6e4a26'; // fur tunic + var FUR_D = '#553818'; + var BONE = '#e8dcc4'; + + function rr(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + function ell(ctx, x, y, rx, ry, rot) { + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, rot || 0, 0, Math.PI * 2); + } + function tri(ctx, ax, ay, bx, by, cx2, cy2) { + ctx.beginPath(); + ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx2, cy2); + ctx.closePath(); + } + function shadow(ctx, x, y, rx, ry) { + ctx.fillStyle = 'rgba(10,8,5,0.38)'; + ell(ctx, x + rx * 0.25, y + ry * 0.45, rx, ry); + ctx.fill(); + } + + /* ================================================================ + Arena ground & props + ================================================================ */ + function makeDecor(W, H, WALL) { + var d = { grass: [], rocks: [], bones: [], ferns: [], patches: [] }; + var i; + for (i = 0; i < 16; i++) { + d.patches.push({ + x: WALL + Math.random() * (W - WALL * 2), + y: WALL + Math.random() * (H - WALL * 2), + rx: 60 + Math.random() * 130, + ry: 40 + Math.random() * 80, + rot: Math.random() * Math.PI + }); + } + for (i = 0; i < 90; i++) { + d.grass.push({ + x: WALL + Math.random() * (W - WALL * 2), + y: WALL + Math.random() * (H - WALL * 2), + s: 0.7 + Math.random() * 0.9, + p: Math.random() * Math.PI * 2 + }); + } + for (i = 0; i < 14; i++) { + d.rocks.push({ + x: WALL + 30 + Math.random() * (W - WALL * 2 - 60), + y: WALL + 30 + Math.random() * (H - WALL * 2 - 60), + r: 7 + Math.random() * 15, + pts: 5 + Math.floor(Math.random() * 3), + seed: Math.random() * 10 + }); + } + for (i = 0; i < 12; i++) { + d.bones.push({ + x: WALL + Math.random() * (W - WALL * 2), + y: WALL + Math.random() * (H - WALL * 2), + rot: Math.random() * Math.PI, + skull: Math.random() < 0.35, + s: 0.8 + Math.random() * 0.7 + }); + } + for (i = 0; i < 18; i++) { + d.ferns.push({ + x: WALL + Math.random() * (W - WALL * 2), + y: WALL + Math.random() * (H - WALL * 2), + s: 0.8 + Math.random() * 0.8, + rot: Math.random() * Math.PI * 2 + }); + } + // tar pits — sticky slow zones (kept clear of the central camp) + d.tarPits = []; + for (i = 0; i < 2; i++) { + var ang = i * Math.PI + 0.7; + d.tarPits.push({ + x: W / 2 + Math.cos(ang) * (W * 0.31) + Math.random() * 40, + y: H / 2 + Math.sin(ang) * (H * 0.30) + Math.random() * 40, + r: 58 + Math.random() * 22, + seed: Math.random() * 9 + }); + } + // giant bone landmarks: a buried skull & a ribcage arch + d.bigProps = [ + { type: 'skull', x: WALL + 150, y: H - WALL - 130, s: 2.6, rot: -0.35 }, + { type: 'ribs', x: W - WALL - 170, y: WALL + 140, s: 2.4, rot: 0.25 } + ]; + // tribal totem stakes at the four inner corners + d.totems = [ + { x: WALL + 46, y: WALL + 46 }, + { x: W - WALL - 46, y: WALL + 46 }, + { x: WALL + 46, y: H - WALL - 46 }, + { x: W - WALL - 46, y: H - WALL - 46 } + ]; + return d; + } + + function drawBiomeFx(ctx, g) { + var t = g.time || 0; + if (g.biome === 'swamp') { + // low creeping fog banks + for (var fi = 0; fi < 3; fi++) { + var fx2 = ((fi * 517 + t * (9 + fi * 5)) % (g.W + 700)) - 350; + var fy2 = g.H * (0.28 + fi * 0.22) + Math.sin(t * 0.23 + fi) * 30; + ctx.fillStyle = 'rgba(150,190,150,0.055)'; + ell(ctx, fx2, fy2, 260 + fi * 55, 62 + fi * 14, fi * 0.7); + ctx.fill(); + ell(ctx, fx2 + 170, fy2 + 26, 150 + fi * 40, 44, -fi * 0.5); + ctx.fill(); + } + } else if (g.biome === 'ash' && g.decor && g.decor.cracks) { + // pulsing lava veins + var pulse = 0.55 + Math.abs(Math.sin(t * 2.1)) * 0.45; + ctx.lineCap = 'round'; + for (var ci = 0; ci < g.decor.cracks.length; ci++) { + var cr = g.decor.cracks[ci]; + ctx.strokeStyle = 'rgba(255,110,30,' + (0.75 * pulse).toFixed(2) + ')'; + ctx.lineWidth = 5; + ctx.beginPath(); + for (var si = 0; si < cr.pts.length; si++) { + var pt3 = cr.pts[si]; + if (si === 0) ctx.moveTo(cr.x + pt3.x, cr.y + pt3.y); + else ctx.lineTo(cr.x + pt3.x, cr.y + pt3.y); + } + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,210,90,' + (0.5 * pulse).toFixed(2) + ')'; + ctx.lineWidth = 2; + ctx.stroke(); + } + ctx.lineCap = 'butt'; + } + } + + /* ---------------- primeval landmarks -------------------------------- */ + function drawTarPit(ctx, tp, t) { + ctx.fillStyle = '#151009'; + ell(ctx, tp.x, tp.y, tp.r, tp.r * 0.72); + ctx.fill(); + ell(ctx, tp.x + tp.r * 0.3, tp.y - tp.r * 0.15, tp.r * 0.55, tp.r * 0.4); + ctx.fill(); + // oily sheen + ctx.fillStyle = 'rgba(90,70,45,0.20)'; + ell(ctx, tp.x - tp.r * 0.2, tp.y - tp.r * 0.18, tp.r * 0.5, tp.r * 0.28, -0.3); + ctx.fill(); + // slow bubbles + for (var b = 0; b < 3; b++) { + var cyc = (t * 0.35 + b / 3 + tp.seed) % 1; + var bx = tp.x + Math.sin(tp.seed * 7 + b * 2.4) * tp.r * 0.5; + var by = tp.y + Math.cos(tp.seed * 5 + b * 1.7) * tp.r * 0.3; + var br = (1 - cyc) * 4 + 1; + ctx.fillStyle = 'rgba(60,48,32,' + (cyc * 0.55).toFixed(2) + ')'; + ell(ctx, bx, by, br, br * 0.8); + ctx.fill(); + } + } + + function drawGiantSkull(ctx, pr, t) { + shadow(ctx, pr.x + 10, pr.y + 14, 60 * pr.s * 0.5, 26 * pr.s * 0.5); + ctx.save(); + ctx.translate(pr.x, pr.y); + ctx.rotate(pr.rot); + ctx.scale(pr.s, pr.s); + // cranium half-buried in the dirt + ctx.fillStyle = '#cfc2a4'; + ctx.strokeStyle = '#8f8062'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(-34, 6); + ctx.quadraticCurveTo(-36, -26, -8, -30); + ctx.quadraticCurveTo(24, -32, 34, -12); + ctx.lineTo(38, 14); + ctx.quadraticCurveTo(10, 22, -34, 6); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + // dark eye sockets + nasal slit + ctx.fillStyle = '#171009'; + ell(ctx, 8, -12, 11, 13, 0.15); ctx.fill(); + ell(ctx, 30, -8, 8, 10, -0.2); ctx.fill(); + tri(ctx, 18, 2, 23, 12, 28, 2); ctx.fill(); + // upper teeth row along the jaw line + ctx.fillStyle = '#e8dcc4'; + for (var th = 0; th < 6; th++) { + tri(ctx, -30 + th * 11, 10 + th * 0.8, -26 + th * 11, 22 + th * 0.6, -22 + th * 11, 10 + th * 1.1); + ctx.fill(); + } + // cracks + ctx.strokeStyle = 'rgba(80,66,44,0.7)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(-16, -26); ctx.lineTo(-10, -14); ctx.lineTo(-16, -4); + ctx.moveTo(14, -28); ctx.lineTo(18, -18); + ctx.stroke(); + // grass tufts claiming it back + ctx.strokeStyle = '#46601f'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(-30, 12); ctx.lineTo(-33, 4); + ctx.moveTo(-27, 13); ctx.lineTo(-28, 5); + ctx.stroke(); + ctx.restore(); + } + + function drawRibcage(ctx, pr, t) { + shadow(ctx, pr.x, pr.y + 8, 52 * pr.s * 0.5, 14 * pr.s * 0.5); + ctx.save(); + ctx.translate(pr.x, pr.y); + ctx.rotate(pr.rot); + ctx.scale(pr.s, pr.s); + // spine + ctx.fillStyle = '#cfc2a4'; + rr(ctx, -50, -4, 104, 7, 3); + ctx.fill(); + ctx.strokeStyle = '#8f8062'; + ctx.lineWidth = 1.4; + ctx.stroke(); + // curving ribs on both sides, weathered gaps + ctx.strokeStyle = '#d8ccb0'; + ctx.lineWidth = 5.4; + ctx.lineCap = 'round'; + for (var rb = 0; rb < 5; rb++) { + var rx = -42 + rb * 21; + ctx.beginPath(); + ctx.moveTo(rx, 0); + ctx.quadraticCurveTo(rx - 4, 26, rx + 8, 44 - rb * 2); + ctx.stroke(); + if (rb % 2 === 0) { + ctx.beginPath(); + ctx.moveTo(rx, 0); + ctx.quadraticCurveTo(rx + 2, -18, rx - 6, -30 + rb * 2); + ctx.stroke(); + } + } + ctx.lineCap = 'butt'; + ctx.restore(); + } + + function drawTotem(ctx, tt, t) { + shadow(ctx, tt.x + 5, tt.y + 8, 12, 7); + // carved pole + ctx.fillStyle = '#4a3620'; + ctx.fillRect(tt.x - 7, tt.y - 34, 14, 46); + ctx.strokeStyle = '#2b1d0d'; + ctx.lineWidth = 1.6; + ctx.strokeRect(tt.x - 7, tt.y - 34, 14, 46); + // stacked carved faces + ctx.fillStyle = '#6b4a26'; + ell(ctx, tt.x, tt.y - 24, 6.5, 6); + ctx.fill(); + ctx.fillStyle = '#171009'; + ell(ctx, tt.x - 2.6, tt.y - 25, 1.5, 1.8); ctx.fill(); + ell(ctx, tt.x + 2.6, tt.y - 25, 1.5, 1.8); ctx.fill(); + ctx.fillStyle = '#6b4a26'; + ell(ctx, tt.x, tt.y - 8, 6, 5.4); + ctx.fill(); + ctx.strokeStyle = BONE; + ctx.lineWidth = 1.4; + ctx.beginPath(); ctx.moveTo(tt.x - 4, tt.y - 6); ctx.lineTo(tt.x + 4, tt.y - 6); ctx.stroke(); + // small flame on top, alive + var fh = 9 + Math.sin(t * 11 + tt.x) * 2.4; + ctx.fillStyle = '#e04b3a'; + tri(ctx, tt.x - 4.5, tt.y - 34, tt.x, tt.y - 34 - fh, tt.x + 4.5, tt.y - 34); + ctx.fill(); + ctx.fillStyle = '#ffb938'; + tri(ctx, tt.x - 2.4, tt.y - 34, tt.x, tt.y - 34 - fh * 0.62, tt.x + 2.4, tt.y - 34); + ctx.fill(); + } + + /* ---------------- the tribe's campfire (world centre) --------------- */ + function drawCampfire(ctx, cx, cy, t) { + // flickering warm light pool + var fl = 0.12 + Math.abs(Math.sin(t * 7.3)) * 0.05 + Math.abs(Math.sin(t * 3.1)) * 0.03; + var g1 = ctx.createRadialGradient(cx, cy, 8, cx, cy, 130); + g1.addColorStop(0, 'rgba(255,150,60,' + fl.toFixed(3) + ')'); + g1.addColorStop(1, 'rgba(255,120,40,0)'); + ctx.fillStyle = g1; + ctx.fillRect(cx - 130, cy - 130, 260, 260); + // stone ring + ctx.fillStyle = '#6a6156'; + for (var st2 = 0; st2 < 9; st2++) { + var sa2 = st2 / 9 * Math.PI * 2; + ell(ctx, cx + Math.cos(sa2) * 26, cy + Math.sin(sa2) * 17, 5.5, 4, sa2); + ctx.fill(); + } + // crossed logs + ctx.save(); + ctx.translate(cx, cy); + ctx.fillStyle = '#5c4423'; + ctx.rotate(0.5); rr(ctx, -16, -3.4, 32, 6.8, 3); ctx.fill(); + ctx.rotate(-1.05); rr(ctx, -16, -3.4, 32, 6.8, 3); ctx.fill(); + ctx.restore(); + // layered flames + for (var f2 = 0; f2 < 3; f2++) { + var jx = Math.sin(t * 13 + f2 * 2.2) * 3; + var hh = [22, 15, 9][f2]; + var cols = ['#c22f1d', '#ff8a3d', '#ffd24a']; + ctx.fillStyle = cols[f2]; + ctx.beginPath(); + ctx.moveTo(cx - (9 - f2 * 2.4), cy - 2); + ctx.quadraticCurveTo(cx - (5 - f2), cy - 2 - hh * 0.62, cx + jx, cy - 2 - hh); + ctx.quadraticCurveTo(cx + (5 - f2), cy - 2 - hh * 0.62, cx + (9 - f2 * 2.4), cy - 2); + ctx.closePath(); + ctx.fill(); + } + // rising sparks + for (var sp3 = 0; sp3 < 3; sp3++) { + var cyc2 = (t * 0.9 + sp3 / 3) % 1; + ctx.globalAlpha = (1 - cyc2) * 0.85; + ctx.fillStyle = sp3 % 2 ? '#ffb938' : '#ff8a3d'; + ell(ctx, + cx + Math.sin(t * 3 + sp3 * 5) * (4 + sp3 * 3), + cy - 10 - cyc2 * 34, 1.7, 1.7, 0); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + /* ---------------- biomes: jungle -> swamp -> ashlands ----------------- */ + var BIOMES = { + jungle: { inner: '#3d2f1d', outer: '#191207', patch: 'rgba(120,92,54,0.13)', + grass: ['#46601f', '#58a55c', '#6f8f3a'], rim: 'rgba(190,160,105,0.16)' }, + swamp: { inner: '#22301c', outer: '#0e1509', patch: 'rgba(70,110,70,0.14)', + grass: ['#2f4a22', '#3c5a2a', '#4f7a35'], rim: 'rgba(140,180,130,0.13)' }, + ash: { inner: '#33221a', outer: '#120b08', patch: 'rgba(150,60,25,0.10)', + grass: ['#4a3428', '#5c4030', '#3a2a20'], rim: 'rgba(255,140,70,0.14)' } + }; + + function drawGround(g, ctx) { + var tNow = g.time || performance.now() / 1000; + var PAL = BIOMES[g.biome] || BIOMES.jungle; + // packed-dirt base with dusk vignette, tinted by biome + var grad = ctx.createRadialGradient(g.W / 2, g.H / 2, g.H * 0.25, g.W / 2, g.H / 2, g.H * 0.85); + grad.addColorStop(0, PAL.inner); + grad.addColorStop(1, PAL.outer); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, g.W, g.H); + + if (!g.decor) return; + var d = g.decor, i, j; + + // lighter trampled patches (biome-tinted) + ctx.fillStyle = PAL.patch; + for (i = 0; i < d.patches.length; i++) { + var pa = d.patches[i]; + ell(ctx, pa.x, pa.y, pa.rx, pa.ry, pa.rot); + ctx.fill(); + } + // home-turf circle at center + ctx.strokeStyle = PAL.rim; + ctx.lineWidth = 10; + ell(ctx, g.W / 2, g.H / 2, 130, 84); + ctx.stroke(); + + // grass tufts (biome palette) + ctx.lineWidth = 2; + for (i = 0; i < d.grass.length; i++) { + var gr = d.grass[i]; + ctx.strokeStyle = PAL.grass[i % 3]; + var sway = Math.sin(gr.p + performance.now() / 900) * 2 * gr.s; + for (j = -1; j <= 1; j++) { + ctx.beginPath(); + ctx.moveTo(gr.x, gr.y); + ctx.quadraticCurveTo(gr.x + j * 3 * gr.s, gr.y - 6 * gr.s, gr.x + j * 4 * gr.s + sway, gr.y - 11 * gr.s); + ctx.stroke(); + } + } + // ferns + for (i = 0; i < d.ferns.length; i++) { + var f = d.ferns[i]; + ctx.save(); + ctx.translate(f.x, f.y); + ctx.rotate(f.rot); + ctx.scale(f.s, f.s); + ctx.strokeStyle = '#37531f'; + for (var k = -2; k <= 2; k++) { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(k * 5, -9, k * 9, -17); + ctx.stroke(); + } + ctx.restore(); + } + // rocks + for (i = 0; i < d.rocks.length; i++) { + var rk = d.rocks[i]; + shadow(ctx, rk.x, rk.y, rk.r, rk.r * 0.6); + ctx.fillStyle = '#6a6156'; + ctx.beginPath(); + for (j = 0; j < rk.pts; j++) { + var a = j / rk.pts * Math.PI * 2 + rk.seed; + var rad = rk.r * (0.75 + ((Math.sin(rk.seed * 9 + j * 3) + 1) / 2) * 0.4); + ctx[j ? 'lineTo' : 'moveTo'](rk.x + Math.cos(a) * rad, rk.y + Math.sin(a) * rad); + } + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(255,240,210,0.14)'; + ell(ctx, rk.x - rk.r * 0.25, rk.y - rk.r * 0.3, rk.r * 0.4, rk.r * 0.22, -0.5); + ctx.fill(); + } + // old bones + for (i = 0; i < d.bones.length; i++) { + var b = d.bones[i]; + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.rot); + ctx.scale(b.s, b.s); + ctx.fillStyle = BONE; + ctx.globalAlpha = 0.85; + if (b.skull) { + ell(ctx, 0, 0, 8, 6.5); + ctx.fill(); + ctx.fillStyle = '#241a10'; + ell(ctx, -3, -2, 1.7, 2.2); ctx.fill(); + ell(ctx, 3, -2, 1.7, 2.2); ctx.fill(); + ctx.fillRect(-3, 4, 6, 2.4); + } else { + ell(ctx, -8, 0, 4.5, 3.2); ctx.fill(); + ell(ctx, 8, 0, 4.5, 3.2); ctx.fill(); + ctx.fillRect(-7, -1.6, 14, 3.2); + } + ctx.restore(); + } + ctx.globalAlpha = 1; + + // tar pits (slow zones) + if (d.tarPits) for (i = 0; i < d.tarPits.length; i++) drawTarPit(ctx, d.tarPits[i], tNow); + // giant bone landmarks + if (d.bigProps) for (i = 0; i < d.bigProps.length; i++) { + var bp = d.bigProps[i]; + if (bp.type === 'skull') drawGiantSkull(ctx, bp, tNow); + else drawRibcage(ctx, bp, tNow); + } + // tribal totem stakes + if (d.totems) for (i = 0; i < d.totems.length; i++) drawTotem(ctx, d.totems[i], tNow); + // the tribe's campfire at the heart of the arena + drawCampfire(ctx, g.W / 2, g.H / 2, tNow); + + // palisade wall of logs around the arena + var stepL = 15; + var n = 0; + for (var x = g.WALL; x <= g.W - g.WALL; x += stepL, n++) { + logPost(ctx, x, g.WALL, n); + logPost(ctx, x, g.H - g.WALL - 12, n + 37); + } + for (var y = g.WALL + stepL; y < g.H - g.WALL; y += stepL, n++) { + logPostV(ctx, g.WALL, y, n + 71); + logPostV(ctx, g.W - g.WALL - 12, y, n + 113); + } + } + function logPost(ctx, x, y, n) { + var h = 13 + (n % 3); + ctx.fillStyle = '#463319'; + ctx.fillRect(x, y, 9, h); + ctx.fillStyle = '#5c4423'; + ctx.fillRect(x, y, 9, 3); + ell(ctx, x + 4.5, y + 1.5, 4, 2.2); + ctx.fillStyle = '#7a5c33'; + ctx.fill(); + } + function logPostV(ctx, x, y, n) { + var h = 13 + (n % 3); + ctx.fillStyle = '#463319'; + ctx.fillRect(x, y, h, 9); + ctx.fillStyle = '#5c4423'; + ctx.fillRect(x, y, 3, 9); + } + + /* ================================================================ + The tribal hero (player & rival tribesmen) + Top-down but chunky & cel-outlined so it reads instantly: + planted striding feet, fur tunic, war-paint sash, two-segment arms + gripping a real weapon, hair bun + bone pin, nose showing facing, + recoil + muzzle flash while firing, idle breathing. + ================================================================ */ + var OUT = '#241708'; + + // u: {x,y,radius,aim,walkPhase,moving,color(accent paint),trim, + // weaponKind,label,hurtT,fireT,scale,seedF} + function drawHuman(ctx, u, t) { + var moving = !!u.moving; + var phase = u.walkPhase || 0; + var stepA = moving ? Math.sin(phase) : 0; + var s = u.scale || 1; + var fire = u.fireT > 0 ? Math.min(1, u.fireT / 0.09) : 0; + + shadow(ctx, u.x, u.y, u.radius * 1.25 * s, u.radius * 0.85 * s); + + ctx.save(); + ctx.translate(u.x, u.y); + ctx.scale(s, s); + ctx.rotate(u.aim || 0); + + // ---- planted feet (under the tunic hem, alternate stride) ---- + ctx.fillStyle = '#4b3218'; + ctx.strokeStyle = OUT; + ctx.lineWidth = 1.6; + var fy = moving ? 8 : 7; + ell(ctx, -3 + stepA * 6, -fy, 4.8, 3, 0.25); + ctx.fill(); ctx.stroke(); + ell(ctx, -3 - stepA * 6, fy, 4.8, 3, -0.25); + ctx.fill(); ctx.stroke(); + + // ---- everything above the feet recoils when firing ---- + ctx.save(); + ctx.translate(-fire * 2.6, 0); + + // breathing / run bob + var pulse = moving ? Math.abs(Math.sin(phase)) * 0.045 : Math.sin(t * 2.3 + (u.seedF || 0)) * 0.02; + ctx.scale(1 + pulse, 1 - pulse * 0.6); + + // tunic dyed in the tribe's colours (falls back to plain fur) + var OUTFIT = u.body || FUR; + ctx.fillStyle = OUTFIT; + rr(ctx, -11, -10.5, 20, 21, 9); + ctx.fill(); + ctx.strokeStyle = OUT; + ctx.lineWidth = 2; + ctx.stroke(); + // lighter lap band + texture notches + ctx.fillStyle = 'rgba(255,235,200,0.18)'; + rr(ctx, -8.5, -8, 15, 16, 7); + ctx.fill(); + ctx.strokeStyle = 'rgba(20,12,4,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(-7, -6); ctx.lineTo(-3, -3); + ctx.moveTo(-8, 1); ctx.lineTo(-4, 4); + ctx.moveTo(-7, 8); ctx.lineTo(-3, 5); + ctx.stroke(); + + // shoulder tufts shaded to match the dye + ctx.fillStyle = shade(OUTFIT, -28); + ctx.strokeStyle = OUT; + ctx.lineWidth = 1.4; + ell(ctx, -4, -9.5, 3.4, 2.6, -0.4); ctx.fill(); ctx.stroke(); + ell(ctx, -4, 9.5, 3.4, 2.6, 0.4); ctx.fill(); ctx.stroke(); + + // war-paint sash across the chest + ctx.strokeStyle = OUT; + ctx.lineWidth = 6.4; + ctx.beginPath(); ctx.moveTo(-4.5, -10); ctx.quadraticCurveTo(3, 0, -4.5, 10); ctx.stroke(); + ctx.strokeStyle = u.color || '#e04b3a'; + ctx.lineWidth = 4; + ctx.beginPath(); ctx.moveTo(-4.5, -9.6); ctx.quadraticCurveTo(3, 0, -4.5, 9.6); ctx.stroke(); + + // ---- arms: dark under-line then skin => cartoon outline ---- + var handX = 14 + fire * 3; + function arm(sy, hy) { + ctx.lineCap = 'round'; + ctx.strokeStyle = OUT; ctx.lineWidth = 6.6; + ctx.beginPath(); + ctx.moveTo(0, sy); ctx.quadraticCurveTo(7, sy * 0.85, handX, hy); + ctx.stroke(); + ctx.strokeStyle = SKIN; ctx.lineWidth = 4.2; + ctx.beginPath(); + ctx.moveTo(0, sy); ctx.quadraticCurveTo(7, sy * 0.85, handX, hy); + ctx.stroke(); + } + arm(-8.5, -3); + arm(8.5, 3); + + // ---- weapon in the grip (returns tip x for muzzle flash) ---- + var tipX = drawHeldWeapon(ctx, u.weaponKind, fire); + + // ---- night torch: the reason this hunter carries a light halo ---- + if (u.torch) { + ctx.save(); + ctx.translate(handX - 2, 4); + ctx.rotate(-0.5); + // shaft + st(ctx, OUT, 6.2); line(ctx, 0, 6, 0, -8); + st(ctx, '#7a5230', 3.8); line(ctx, 0, 6, 0, -8); + // wrapped head + ctx.fillStyle = '#c9a15a'; + ell(ctx, 0, -10, 3.4, 3, 0); ctx.fill(); + ctx.strokeStyle = OUT; ctx.lineWidth = 1.4; + ctx.stroke(); + // layered flame + var fh2 = 11 + Math.sin(t * 12 + (u.seedF || 0)) * 2.6; + ctx.fillStyle = '#c22f1d'; + tri(ctx, -4, -12, 0, -12 - fh2, 4, -12); ctx.fill(); + ctx.fillStyle = '#ff8a3d'; + tri(ctx, -2.6, -12, 0, -12 - fh2 * 0.66, 2.6, -12); ctx.fill(); + ctx.fillStyle = '#ffd24a'; + tri(ctx, -1.4, -12, 0, -12 - fh2 * 0.38, 1.4, -12); ctx.fill(); + // stray spark + if ((t * 2 + (u.seedF || 0)) % 1 < 0.5) { + ctx.fillStyle = '#ffb938'; + ell(ctx, Math.sin(t * 7) * 3, -14 - fh2 * 0.9, 1.5, 1.5, 0); + ctx.fill(); + } + ctx.restore(); + } + + // ---- head stack ---- + // hair mass + ctx.fillStyle = '#33240f'; + ctx.strokeStyle = OUT; + ctx.lineWidth = 1.8; + ell(ctx, -1.5, 0, 7.6, 7.9, 0); + ctx.fill(); ctx.stroke(); + // top-knot bun + bone pin + ctx.fillStyle = '#33240f'; + ell(ctx, -6.5, 0, 3.4, 3.4, 0); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = BONE; + ctx.lineWidth = 1.7; + ctx.beginPath(); + ctx.moveTo(-10.5, -2.6); ctx.lineTo(-3.2, 2.6); + ctx.stroke(); + // two feathers crossing behind the head + feather(ctx, Math.PI - 0.30, u.color || '#e04b3a', t, u.seedF || 0, 15); + feather(ctx, Math.PI + 0.24, '#e8dcc4', t, (u.seedF || 0) + 2.1, 12); + // face/crown + ctx.fillStyle = SKIN; + ell(ctx, 2.8, 0, 6.4, 6.8, 0); + ctx.fill(); + ctx.strokeStyle = OUT; + ctx.lineWidth = 1.8; + ctx.stroke(); + // nose bump points where you are looking + ctx.fillStyle = SKIN; + tri(ctx, 7.6, -2.2, 10.8, 0, 7.6, 2.2); + ctx.fill(); + ctx.strokeStyle = OUT; + ctx.stroke(); + // headband + ctx.strokeStyle = OUT; + ctx.lineWidth = 4.6; + ctx.beginPath(); ctx.arc(0.6, 0, 6.6, -Math.PI * 0.42, Math.PI * 0.42); ctx.stroke(); + ctx.strokeStyle = u.color || '#e04b3a'; + ctx.lineWidth = 2.8; + ctx.beginPath(); ctx.arc(0.6, 0, 6.6, -Math.PI * 0.40, Math.PI * 0.40); ctx.stroke(); + + ctx.restore(); // recoil/bob group + + // ---- muzzle flash at the weapon tip ---- + if (fire > 0) { + ctx.globalAlpha = fire; + var mx2 = tipX + 3; + ctx.fillStyle = 'rgba(255,185,56,0.9)'; + for (var spk = 0; spk < 4; spk++) { + var sa = (spk / 4) * Math.PI * 2 + t * 30; + tri(ctx, mx2, 0, + mx2 + Math.cos(sa) * (7 + fire * 5), Math.sin(sa) * (7 + fire * 5), + mx2 + Math.cos(sa + 0.5) * 2, Math.sin(sa + 0.5) * 2); + ctx.fill(); + } + ctx.fillStyle = '#fff3c4'; + ell(ctx, mx2 + 1, 0, 3.2 + fire * 2, 2.6 + fire * 1.6, 0); + ctx.fill(); + ctx.globalAlpha = 1; + } + + ctx.restore(); // aim/position + + // hurt flash + if (u.hurtT > 0) { + ctx.fillStyle = 'rgba(224,75,58,' + Math.min(0.5, u.hurtT) + ')'; + ctx.beginPath(); + ctx.ellipse(u.x, u.y, u.radius + 5, u.radius + 3, 0, 0, Math.PI * 2); + ctx.fill(); + } + + // name tag for rival tribesmen + if (u.label) { + ctx.fillStyle = '#f3e7cf'; + ctx.font = 'bold 10px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(u.label, u.x, u.y - u.radius - 13); + } + } + + function feather(ctx, baseAng, col, t, seed, len) { + ctx.save(); + ctx.rotate(baseAng + Math.sin(t * 6 + seed) * 0.12); + ctx.fillStyle = col; + ctx.strokeStyle = OUT; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(2, 0); + ctx.quadraticCurveTo(len * 0.55, -3.4, len, -1.2); + ctx.quadraticCurveTo(len * 0.55, 2.6, 2, 0); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = 'rgba(36,23,8,0.55)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(3, 0); ctx.lineTo(len - 2, -0.6); + ctx.stroke(); + ctx.restore(); + } + + // draws the held weapon pointing along +x; returns the muzzle tip x + function drawHeldWeapon(ctx, kind, fire) { + var tip = 30; + switch (kind) { + case 'slingshot': + st(ctx, '#7a5230', 5.6); line(ctx, 10, 0, 20, 0); prong(ctx, 20, 0, 26, -5.5); prong(ctx, 20, 0, 26, 5.5); + st(ctx, '#8f6636', 3.2); line(ctx, 10, 0, 20, 0); prong(ctx, 20, 0, 26, -5.5); prong(ctx, 20, 0, 26, 5.5); + st(ctx, 'rgba(240,230,210,0.9)', 1.5); + ctx.beginPath(); ctx.moveTo(26, -5.5); ctx.quadraticCurveTo(29 - fire * 3, 0, 26, 5.5); ctx.stroke(); + ctx.fillStyle = stoneCol; + ell(ctx, 29 - fire * 3, 0, 2.4, 2.4, 0); ctx.fill(); + tip = 31; + break; + case 'blowpipe': + st(ctx, OUT, 5.4); line(ctx, 5, 3, 28, 3); + st(ctx, '#8a6234', 3.2); line(ctx, 5, 3, 28, 3); + st(ctx, '#5c4423', 3.2); line(ctx, 5, 3, 8, 3); + ctx.fillStyle = BONE; ell(ctx, 28.5, 3, 2, 2, 0); ctx.fill(); + tip = 30; + break; + case 'spears': + st(ctx, OUT, 5.6); line(ctx, 2, 4, 27, 4); + st(ctx, '#8a6234', 3.4); line(ctx, 2, 4, 27, 4); + st(ctx, '#5c4423', 3.4); line(ctx, 12, 4, 15, 4); line(ctx, 20, 4, 23, 4); + ctx.fillStyle = stoneCol; + ctx.strokeStyle = OUT; ctx.lineWidth = 1.5; + tri(ctx, 27, 0.6, 36, 4, 27, 7.4); + ctx.fill(); ctx.stroke(); + tip = 37; + break; + case 'obelisk': + st(ctx, OUT, 5.6); line(ctx, 2, 4, 23, 4); + st(ctx, '#6b4a26', 3.4); line(ctx, 2, 4, 23, 4); + ctx.fillStyle = '#4b4457'; + ctx.strokeStyle = OUT; ctx.lineWidth = 1.5; + tri(ctx, 23, -1, 39, 4, 23, 9); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = 'rgba(199,146,255,0.75)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); ctx.moveTo(26, 3.4); ctx.lineTo(35, 4); ctx.stroke(); + tip = 40; + break; + case 'trebuchet': + default: + st(ctx, OUT, 5.8); line(ctx, 4, 4, 18, 4); + st(ctx, '#7a5230', 3.6); line(ctx, 4, 4, 18, 4); + ctx.fillStyle = '#6a6156'; + ctx.strokeStyle = OUT; ctx.lineWidth = 1.7; + ell(ctx, 24, 4, 6.6, 5.6, 0.2); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1.3; + ctx.beginPath(); ctx.moveTo(21, 1.6); ctx.lineTo(25, 4.6); ctx.stroke(); + tip = 31; + } + ctx.lineCap = 'butt'; + return tip; + } + function st(ctx, c, w) { ctx.strokeStyle = c; ctx.lineWidth = w; ctx.lineCap = 'round'; } + function line(ctx, x0, y0, x1, y1) { ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); } + function prong(ctx, x0, y0, x1, y1) { ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); } + var stoneCol = '#8f8578'; + + /* ================================================================ + Dinosaurs — shared bits + ================================================================ */ + function legsPair(ctx, off, phase, len, wid, col, dark) { + var s = Math.sin(phase) * len * 0.35; + ctx.fillStyle = dark; + ell(ctx, off + s, -len * 0.42, wid, len * 0.32, 0.3); + ctx.fill(); + ell(ctx, off - s, len * 0.42, wid, len * 0.32, -0.3); + ctx.fill(); + } + + function taperTail(ctx, x0, y0, len, wid, sway, col) { + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(x0, y0 - wid); + ctx.quadraticCurveTo(x0 - len * 0.5, y0 - wid * 0.7 + sway * 0.4, x0 - len, y0 + sway); + ctx.quadraticCurveTo(x0 - len * 0.5, y0 + wid * 0.7 + sway * 0.4, x0, y0 + wid); + ctx.closePath(); + ctx.fill(); + } + + function hpSliver(ctx, e, col) { + if (e.hp >= e.maxHp) return; + var r = e.radius; + ctx.fillStyle = 'rgba(20,14,8,0.72)'; + ctx.fillRect(e.x - r, e.y - r - 10, r * 2, 4.4); + ctx.fillStyle = col || '#e04b3a'; + ctx.fillRect(e.x - r, e.y - r - 10, r * 2 * Math.max(0, e.hp / e.maxHp), 4.4); + } + + /* ---------------- Raptors (grunt / compy / alpha / hatchling) ------- */ + function drawRaptor(ctx, e, t, opts) { + opts = opts || {}; + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -30); + var ph = t * 11 + (e.seed || 0) * 7; + var sway = Math.sin(t * 7 + (e.seed || 0) * 5) * r * 0.35; + + shadow(ctx, e.x, e.y, r * 1.35, r * 0.8); + + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.face || 0); + + taperTail(ctx, -r * 0.7, 0, r * 2.1, r * 0.42, sway, dark); + + legsPair(ctx, -r * 0.25, ph, r * 1.05, r * 0.34, col, dark); + + // body + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.08, r * 0.68); + ctx.fill(); + // back stripes + ctx.strokeStyle = 'rgba(30,20,8,0.5)'; + ctx.lineWidth = 2; + for (var i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.arc(i * r * 0.5, 0, r * 0.5, Math.PI * 0.65, Math.PI * 1.35); + ctx.stroke(); + } + // belly highlight + ctx.fillStyle = 'rgba(255,235,200,0.18)'; + ell(ctx, r * 0.05, 0, r * 0.7, r * 0.34); + ctx.fill(); + + // little clawed arms + ctx.strokeStyle = dark; + ctx.lineWidth = 2.4; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(r * 0.55, -r * 0.4); ctx.lineTo(r * 0.95, -r * 0.62); + ctx.moveTo(r * 0.55, r * 0.4); ctx.lineTo(r * 0.95, r * 0.62); + ctx.stroke(); + + // neck + head with animated jaw + var jaw = 0.25 + 0.2 * Math.sin(t * 6 + (e.seed || 0)); + ctx.fillStyle = col; + ell(ctx, r * 0.95, 0, r * 0.42, r * 0.34); + ctx.fill(); + ctx.save(); + ctx.translate(r * 1.15, 0); + // upper snout + ctx.fillStyle = col; + tri(ctx, -r * 0.15, -r * 0.3, r * 0.75, -r * 0.06, -r * 0.15, r * 0.06); + ctx.fill(); + // lower jaw drops + ctx.save(); + ctx.rotate(jaw); + ctx.fillStyle = dark; + tri(ctx, -r * 0.1, r * 0.02, r * 0.62, r * 0.3, -r * 0.1, r * 0.22); + ctx.fill(); + ctx.restore(); + // teeth glint + eye + ctx.fillStyle = BONE; + ctx.fillRect(r * 0.3, r * 0.02, r * 0.3, 1.4); + ctx.fillStyle = opts.alpha ? '#ff5040' : '#1d1408'; + ell(ctx, -r * 0.02, -r * 0.16, 1.6, 1.6); + ctx.fill(); + ctx.restore(); + + if (opts.alpha) { + // feather mane + ctx.strokeStyle = '#c9432f'; + ctx.lineWidth = 2; + for (var m = -2; m <= 2; m++) { + ctx.beginPath(); + ctx.moveTo(r * 0.55, m * r * 0.16); + ctx.lineTo(r * 0.78, m * r * 0.34); + ctx.stroke(); + } + } + ctx.lineCap = 'butt'; + ctx.restore(); + + hpSliver(ctx, e); + } + + /* ---------------- Triceratops (tank) -------------------------------- */ + function drawTriceratops(ctx, e, t) { + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -28); + var ph = t * 7 + (e.seed || 0) * 6; + + shadow(ctx, e.x, e.y, r * 1.3, r * 0.95); + + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.face || 0); + + taperTail(ctx, -r * 0.8, 0, r * 1.1, r * 0.5, Math.sin(t * 5 + e.seed) * r * 0.2, dark); + + // four stocky legs + ctx.fillStyle = dark; + ell(ctx, -r * 0.45 + Math.sin(ph) * 3, -r * 0.62, r * 0.3, r * 0.22, 0.2); + ctx.fill(); + ell(ctx, -r * 0.45 - Math.sin(ph) * 3, r * 0.62, r * 0.3, r * 0.22, -0.2); + ctx.fill(); + ell(ctx, r * 0.4 - Math.sin(ph) * 3, -r * 0.6, r * 0.3, r * 0.22, 0.2); + ctx.fill(); + ell(ctx, r * 0.4 + Math.sin(ph) * 3, r * 0.6, r * 0.3, r * 0.22, -0.2); + ctx.fill(); + + // barrel body + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.05, r * 0.88); + ctx.fill(); + ctx.fillStyle = 'rgba(255,235,200,0.15)'; + ell(ctx, -r * 0.1, 0, r * 0.75, r * 0.5); + ctx.fill(); + + // bony frill behind the head + ctx.fillStyle = dark; + ctx.beginPath(); + ctx.arc(r * 0.55, 0, r * 0.72, -Math.PI * 0.62, Math.PI * 0.62); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = shade(col, -45); + ctx.lineWidth = 2; + for (var i = -2; i <= 2; i++) { + var aa = i * 0.42; + ctx.beginPath(); + ctx.moveTo(r * 0.55 + Math.cos(aa) * r * 0.45, Math.sin(aa) * r * 0.45); + ctx.lineTo(r * 0.55 + Math.cos(aa) * r * 0.68, Math.sin(aa) * r * 0.68); + ctx.stroke(); + } + + // head, beak, three horns + ctx.fillStyle = col; + ell(ctx, r * 1.05, 0, r * 0.4, r * 0.34); + ctx.fill(); + tri(ctx, r * 1.2, -r * 0.18, r * 1.62, 0, r * 1.2, r * 0.18); + ctx.fill(); + ctx.strokeStyle = BONE; + ctx.lineCap = 'round'; + ctx.lineWidth = 3.2; + ctx.beginPath(); + ctx.moveTo(r * 1.0, -r * 0.3); ctx.quadraticCurveTo(r * 1.45, -r * 0.52, r * 1.72, -r * 0.42); + ctx.moveTo(r * 1.0, r * 0.3); ctx.quadraticCurveTo(r * 1.45, r * 0.52, r * 1.72, r * 0.42); + ctx.stroke(); + ctx.lineWidth = 2.6; + ctx.beginPath(); + ctx.moveTo(r * 1.32, 0); ctx.lineTo(r * 1.56, 0); + ctx.stroke(); + ctx.lineCap = 'butt'; + // eye + ctx.fillStyle = '#1d1408'; + ell(ctx, r * 0.86, -r * 0.2, 1.7, 1.7); + ctx.fill(); + ctx.restore(); + + hpSliver(ctx, e); + } + + /* ---------------- Dilophosaurus (spitter) --------------------------- */ + function drawDilo(ctx, e, t) { + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -30); + var ph = t * 10 + (e.seed || 0) * 8; + var aboutToSpit = e.def && e.def.ranged && e.shootCd < 0.45 && e.shootCd > 0; + + shadow(ctx, e.x, e.y, r * 1.25, r * 0.75); + + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.face || 0); + + taperTail(ctx, -r * 0.7, 0, r * 1.9, r * 0.38, Math.sin(t * 7 + e.seed) * r * 0.4, dark); + legsPair(ctx, -r * 0.2, ph, r, r * 0.3, col, dark); + + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.05, r * 0.6); + ctx.fill(); + ctx.fillStyle = 'rgba(255,240,180,0.2)'; + ell(ctx, r * 0.05, 0, r * 0.68, r * 0.28); + ctx.fill(); + + // neck & head + ell(ctx, r * 0.9, 0, r * 0.38, r * 0.3); + ctx.fillStyle = col; + ctx.fill(); + + // threat frill flares right before spitting + if (aboutToSpit) { + ctx.fillStyle = 'rgba(224,75,58,0.82)'; + ctx.beginPath(); + ctx.arc(r * 0.95, 0, r * 1.15, -Math.PI * 0.55, Math.PI * 0.55); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#8f2c1c'; + ctx.lineWidth = 1.6; + for (var s = -3; s <= 3; s++) { + var ra = s * 0.26; + ctx.beginPath(); + ctx.moveTo(r * 0.95 + Math.cos(ra) * r * 0.4, Math.sin(ra) * r * 0.4); + ctx.lineTo(r * 0.95 + Math.cos(ra) * r * 1.1, Math.sin(ra) * r * 1.1); + ctx.stroke(); + } + } + + // double head-crests + ctx.fillStyle = '#e04b3a'; + ell(ctx, r * 1.02, -r * 0.3, r * 0.34, r * 0.16, -0.35); + ctx.fill(); + ell(ctx, r * 1.02, r * 0.3, r * 0.34, r * 0.16, 0.35); + ctx.fill(); + + // slim snout + ctx.fillStyle = col; + tri(ctx, r * 1.05, -r * 0.16, r * 1.7, 0, r * 1.05, r * 0.16); + ctx.fill(); + ctx.fillStyle = '#1d1408'; + ell(ctx, r * 1.06, -r * 0.12, 1.5, 1.5); + ctx.fill(); + ctx.restore(); + + hpSliver(ctx, e); + } + + /* ---------------- Pteranodon (splitter) ----------------------------- */ + function drawPtero(ctx, e, t, chick) { + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -30); + var flap = Math.sin(t * 9 + (e.seed || 0) * 6) * 0.38; + + // flying: smaller offset shadow + ctx.fillStyle = 'rgba(10,8,5,0.25)'; + ell(ctx, e.x + 10, e.y + 14, r * 0.9, r * 0.45); + ctx.fill(); + + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.face || 0); + + // wings + ctx.fillStyle = col; + for (var side = -1; side <= 1; side += 2) { + ctx.save(); + ctx.rotate(side * flap); + ctx.beginPath(); + ctx.moveTo(r * 0.2, side * r * 0.2); + ctx.quadraticCurveTo(-r * 0.4, side * r * 1.5, -r * 0.9, side * r * 1.9); + ctx.quadraticCurveTo(-r * 0.15, side * r * 1.15, r * 0.35, side * r * 0.55); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = dark; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(r * 0.2, side * r * 0.25); + ctx.lineTo(-r * 0.8, side * r * 1.75); + ctx.stroke(); + ctx.restore(); + } + + // body + long tail-less torso + ctx.fillStyle = dark; + ell(ctx, -r * 0.15, 0, r * 0.75, r * 0.3); + ctx.fill(); + + // head with backward crest and forward beak + ctx.fillStyle = col; + ell(ctx, r * 0.55, 0, r * 0.3, r * 0.24); + ctx.fill(); + tri(ctx, r * 0.6, -r * 0.1, r * 1.5, 0, r * 0.6, r * 0.1); + ctx.fill(); + ctx.fillStyle = '#e04b3a'; + tri(ctx, r * 0.42, -r * 0.08, -r * 0.25, -r * 0.5, r * 0.3, -r * 0.04); + ctx.fill(); + ctx.fillStyle = '#1d1408'; + ell(ctx, r * 0.6, 0, 1.4, 1.4); + ctx.fill(); + ctx.restore(); + + if (!chick) hpSliver(ctx, e); + } + + /* ---------------- BOSS: T-Rex ---------------------------------------- */ + function drawRex(ctx, b, t) { + var r = b.radius; + var col = b.hitFlash > 0 ? '#fff6ea' : b.color; + var dark = shade(col, -30); + var charging = b.charging > 0; + var jaw = charging ? 0.85 : 0.3 + 0.18 * Math.sin(t * 3.4); + var ph = t * (charging ? 9 : 5.5); + + shadow(ctx, b.x, b.y, r * 1.45, r * 1.0); + + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.face || 0); + + taperTail(ctx, -r * 0.6, 0, r * 2.2, r * 0.55, Math.sin(t * 3.2 + (b.seed || 0)) * r * 0.3, dark); + + // thick legs + ctx.fillStyle = dark; + ell(ctx, -r * 0.2 + Math.sin(ph) * r * 0.14, -r * 0.55, r * 0.34, r * 0.26, 0.2); + ctx.fill(); + ell(ctx, -r * 0.2 - Math.sin(ph) * r * 0.14, r * 0.55, r * 0.34, r * 0.26, -0.2); + ctx.fill(); + + // body + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.05, r * 0.78); + ctx.fill(); + ctx.fillStyle = 'rgba(255,230,190,0.14)'; + ell(ctx, r * 0.1, 0, r * 0.72, r * 0.42); + ctx.fill(); + + // ridged spine scutes + ctx.fillStyle = shade(col, -45); + for (var i = 0; i < 6; i++) { + var sx = -r * 0.5 + i * r * 0.26; + tri(ctx, sx, -r * 0.72, sx + r * 0.09, -r * 0.95, sx + r * 0.18, -r * 0.72); + ctx.fill(); + } + + // tiny arms with claws + ctx.strokeStyle = dark; + ctx.lineWidth = r * 0.1; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(r * 0.62, -r * 0.34); ctx.lineTo(r * 0.95, -r * 0.48); + ctx.moveTo(r * 0.62, r * 0.34); ctx.lineTo(r * 0.95, r * 0.48); + ctx.stroke(); + + // massive skull with hinged jaw + ctx.fillStyle = col; + ell(ctx, r * 0.95, 0, r * 0.5, r * 0.42); + ctx.fill(); + ctx.save(); + ctx.translate(r * 1.1, 0); + // upper skull block + rr(ctx, -r * 0.18, -r * 0.4, r * 1.05, r * 0.4, r * 0.12); + ctx.fill(); + // brow ridges + ctx.fillStyle = shade(col, -40); + ell(ctx, r * 0.28, -r * 0.34, r * 0.14, r * 0.07, -0.2); + ctx.fill(); + ell(ctx, r * 0.28, r * 0.34, r * 0.14, r * 0.07, 0.2); + ctx.fill(); + // lower jaw rotates open, red maw + teeth + ctx.save(); + ctx.rotate(jaw); + ctx.fillStyle = dark; + rr(ctx, -r * 0.15, r * 0.02, r * 0.98, r * 0.3, r * 0.1); + ctx.fill(); + ctx.restore(); + ctx.fillStyle = '#7e1d10'; + tri(ctx, -r * 0.05, -r * 0.05, r * 0.8, -r * 0.02, r * 0.3, r * 0.3 * jaw + r * 0.05); + ctx.fill(); + // teeth zig-zag + ctx.fillStyle = BONE; + for (var th = 0; th < 5; th++) { + var tx = th * r * 0.16; + tri(ctx, tx, -r * 0.02, tx + r * 0.05, r * 0.14 * (jaw + 0.4), tx + r * 0.1, -r * 0.02); + ctx.fill(); + } + ctx.fillStyle = '#ffd24a'; + ell(ctx, r * 0.3, -r * 0.26, 2.6, 2.6); + ctx.fill(); + ctx.restore(); + + // charge dust + if (charging && Math.random() < 0.4) { + ctx.fillStyle = 'rgba(150,120,80,0.3)'; + ell(ctx, -r, (Math.random() - 0.5) * r, r * 0.3, r * 0.18); + ctx.fill(); + } + ctx.lineCap = 'butt'; + ctx.restore(); + } + + /* ---------------- BOSS: Spinosaurus ---------------------------------- */ + function drawSpino(ctx, b, t) { + var r = b.radius; + var col = b.hitFlash > 0 ? '#fff6ea' : b.color; + var dark = shade(col, -30); + + shadow(ctx, b.x, b.y, r * 1.4, r * 0.9); + + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.face || 0); + + taperTail(ctx, -r * 0.6, 0, r * 2.3, r * 0.45, Math.sin(t * 3 + (b.seed || 0)) * r * 0.35, dark); + + // dorsal SAIL — fan of spines along the back + ctx.fillStyle = dark; + for (var i = 0; i < 7; i++) { + var sx = -r * 0.55 + i * r * 0.24; + var hgt = r * (0.55 + Math.sin(i / 6 * Math.PI) * 0.5); + tri(ctx, sx, -r * 0.55, sx + r * 0.1, -r * 0.55 - hgt, sx + r * 0.2, -r * 0.55); + ctx.fill(); + } + ctx.fillStyle = '#e04b3a'; + for (var j = 1; j < 6; j += 2) { + var sx2 = -r * 0.55 + j * r * 0.24; + tri(ctx, sx2 + r * 0.03, -r * 0.55, sx2 + r * 0.1, -r * 0.55 - r * 0.28, sx2 + r * 0.17, -r * 0.55); + ctx.fill(); + } + + legsPair(ctx, -r * 0.15, t * 6 + (b.seed || 0), r * 0.9, r * 0.3, col, dark); + + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.1, r * 0.55); + ctx.fill(); + + // crocodilian snout + ctx.fillStyle = col; + ell(ctx, r * 1.0, 0, r * 0.42, r * 0.32); + ctx.fill(); + rr(ctx, r * 1.05, -r * 0.2, r * 0.95, r * 0.4, r * 0.16); + ctx.fill(); + ctx.fillStyle = dark; + rr(ctx, r * 1.05, r * 0.04, r * 0.92, r * 0.2, r * 0.1); + ctx.fill(); + ctx.strokeStyle = BONE; + ctx.lineWidth = 1.6; + ctx.beginPath(); + for (var k = 0; k < 5; k++) { + var tx = r * 1.15 + k * r * 0.17; + ctx.moveTo(tx, r * 0.06); + ctx.lineTo(tx + r * 0.05, r * 0.16); + } + ctx.stroke(); + ctx.fillStyle = '#ffd24a'; + ell(ctx, r * 1.02, -r * 0.16, 2.4, 2.4); + ctx.fill(); + ctx.restore(); + } + + /* ---------------- BOSS: Giganotosaurus (splitter) -------------------- */ + function drawGiga(ctx, b, t) { + var r = b.radius; + var col = b.hitFlash > 0 ? '#fff6ea' : b.color; + var dark = shade(col, -32); + var jaw = 0.4 + 0.25 * Math.sin(t * 4); + + shadow(ctx, b.x, b.y, r * 1.4, r * 0.95); + + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.face || 0); + + taperTail(ctx, -r * 0.6, 0, r * 2.0, r * 0.5, Math.sin(t * 3.6 + (b.seed || 0)) * r * 0.32, dark); + legsPair(ctx, -r * 0.15, t * 7 + (b.seed || 0), r * 0.95, r * 0.3, col, dark); + + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.05, r * 0.7); + ctx.fill(); + ctx.strokeStyle = 'rgba(120,20,10,0.55)'; + ctx.lineWidth = r * 0.09; + for (var st = -1; st <= 1; st++) { + ctx.beginPath(); + ctx.arc(st * r * 0.45, 0, r * 0.42, Math.PI * 0.7, Math.PI * 1.3); + ctx.stroke(); + } + + // longer three-fingered arms + ctx.strokeStyle = dark; + ctx.lineWidth = r * 0.11; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(r * 0.6, -r * 0.36); ctx.lineTo(r * 1.05, -r * 0.6); + ctx.moveTo(r * 0.6, r * 0.36); ctx.lineTo(r * 1.05, r * 0.6); + ctx.stroke(); + + // wide serrated skull + ctx.fillStyle = col; + ell(ctx, r * 0.95, 0, r * 0.5, r * 0.4); + ctx.fill(); + ctx.save(); + ctx.translate(r * 1.1, 0); + rr(ctx, -r * 0.15, -r * 0.36, r * 1.1, r * 0.36, r * 0.1); + ctx.fill(); + ctx.save(); + ctx.rotate(jaw); + ctx.fillStyle = dark; + rr(ctx, -r * 0.12, r * 0.02, r * 1.0, r * 0.28, r * 0.09); + ctx.fill(); + ctx.restore(); + ctx.fillStyle = BONE; + for (var th2 = 0; th2 < 6; th2++) { + var tx2 = th2 * r * 0.16; + tri(ctx, tx2, 0, tx2 + r * 0.04, r * 0.13 * (jaw + 0.3), tx2 + r * 0.08, 0); + ctx.fill(); + } + ctx.fillStyle = '#ff5040'; + ell(ctx, r * 0.32, -r * 0.24, 2.4, 2.4); + ctx.fill(); + ctx.restore(); + ctx.lineCap = 'butt'; + ctx.restore(); + } + + /* ---------------- Stegosaurus (plates & thagomizer) ----------------- */ + function drawStego(ctx, e, t) { + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -28); + var ph = t * 6 + (e.seed || 0) * 7; + + shadow(ctx, e.x, e.y, r * 1.3, r * 0.9); + + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.face || 0); + + // thagomizer: four spikes fanning from the tail tip + var sway = Math.sin(t * 4 + (e.seed || 0)) * r * 0.25; + taperTail(ctx, -r * 0.75, 0, r * 1.5, r * 0.42, sway, dark); + ctx.strokeStyle = BONE; + ctx.lineWidth = 2.2; + ctx.lineCap = 'round'; + for (var sp2 = -2; sp2 <= 2; sp2++) { + if (!sp2) continue; + var ta = Math.PI + sp2 * 0.38 + sway * 0.02; + ctx.beginPath(); + ctx.moveTo(-r * 2.15, sway); + ctx.lineTo(-r * 2.15 + Math.cos(ta) * r * 0.55, sway + Math.sin(ta) * r * 0.55); + ctx.stroke(); + } + ctx.lineCap = 'butt'; + + // legs + ctx.fillStyle = dark; + ell(ctx, -r * 0.35 + Math.sin(ph) * 3, -r * 0.58, r * 0.26, r * 0.2, 0.2); + ctx.fill(); + ell(ctx, -r * 0.35 - Math.sin(ph) * 3, r * 0.58, r * 0.26, r * 0.2, -0.2); + ctx.fill(); + + // body + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.05, r * 0.8); + ctx.fill(); + ctx.fillStyle = 'rgba(255,235,200,0.14)'; + ell(ctx, -r * 0.05, 0, r * 0.7, r * 0.46); + ctx.fill(); + + // double row of back plates — kite shapes alternating sizes + for (var i = -3; i <= 3; i++) { + var px = i * r * 0.32; + var hgt = r * (0.5 + Math.sin((i + 3) / 6 * Math.PI) * 0.45); + ctx.fillStyle = i % 2 ? '#d98e2b' : '#c96f2a'; + ctx.beginPath(); + ctx.moveTo(px - r * 0.13, -r * 0.62); + ctx.quadraticCurveTo(px, -r * 0.62 - hgt * 1.25, px + r * 0.13, -r * 0.62); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = i % 2 ? '#b06f1e' : '#a85d20'; + ctx.beginPath(); + ctx.moveTo(px - r * 0.13, r * 0.62); + ctx.quadraticCurveTo(px, r * 0.62 + hgt * 1.25, px + r * 0.13, r * 0.62); + ctx.closePath(); + ctx.fill(); + } + + // small head + ctx.fillStyle = col; + ell(ctx, r * 0.95, 0, r * 0.34, r * 0.26); + ctx.fill(); + tri(ctx, r * 1.1, -r * 0.12, r * 1.5, 0, r * 1.1, r * 0.12); + ctx.fill(); + ctx.fillStyle = '#1d1408'; + ell(ctx, r * 0.98, -r * 0.12, 1.5, 1.5); + ctx.fill(); + ctx.restore(); + + hpSliver(ctx, e); + } + + /* ---------------- Pachycephalosaurus (head-butt charger) ------------- */ + function drawPachy(ctx, e, t) { + var r = e.radius; + var col = e.hitFlash > 0 ? '#fff6ea' : e.color; + var dark = shade(col, -30); + var ph = t * 10 + (e.seed || 0) * 8; + // crouch low during the dash itself + var dashing = e.dashing > 0; + var windup = !dashing && e.dashCd !== undefined && e.dashCd < 0.45; + + shadow(ctx, e.x, e.y, r * 1.2, r * 0.72); + + ctx.save(); + ctx.translate(e.x, e.y); + + // dust when charging + if (dashing && Math.random() < 0.5) { + ctx.fillStyle = 'rgba(150,120,80,0.35)'; + ell(ctx, (Math.random() - 0.5) * r, (Math.random() - 0.5) * r, r * 0.3, r * 0.16); + ctx.fill(); + } + + ctx.rotate(e.face || 0); + + taperTail(ctx, -r * 0.65, 0, r * 1.3, r * 0.34, Math.sin(t * 8 + (e.seed || 0)) * r * 0.3, dark); + legsPair(ctx, -r * 0.15, dashing ? ph * 2 : ph, r, r * 0.28, col, dark); + + ctx.fillStyle = col; + ell(ctx, 0, 0, r * 1.02, r * 0.56); + ctx.fill(); + ctx.fillStyle = 'rgba(255,240,200,0.18)'; + ell(ctx, r * 0.05, 0, r * 0.62, r * 0.26); + ctx.fill(); + + // stocky neck + the famous bone dome + ctx.fillStyle = col; + ell(ctx, r * 0.82, 0, r * 0.36, r * 0.32); + ctx.fill(); + var domeC = windup ? '#ffe9bd' : BONE; + ctx.fillStyle = domeC; + ell(ctx, r * 1.08, 0, r * 0.4, r * 0.36); + ctx.fill(); + ctx.strokeStyle = '#9c7444'; + ctx.lineWidth = 1.6; + for (var k = -1; k <= 1; k++) { + ctx.beginPath(); + ctx.arc(r * 1.08, 0, r * 0.26, k * 0.8 - 0.4, k * 0.8 + 0.4); + ctx.stroke(); + } + // snout bumps & eye + ctx.fillStyle = dark; + ell(ctx, r * 1.38, 0, r * 0.14, r * 0.12); + ctx.fill(); + ctx.fillStyle = windup || dashing ? '#ff5040' : '#1d1408'; + ell(ctx, r * 0.92, -r * 0.16, 1.5, 1.5); + ctx.fill(); + + // warning "!" above while winding up + if (windup) { + ctx.rotate(-(e.face || 0)); + ctx.fillStyle = '#ffb938'; + ctx.font = 'bold ' + Math.round(r * 1.2) + 'px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('!', 0, -r * 1.7); + } + ctx.restore(); + + hpSliver(ctx, e); + } + + /* ---------------- sky & drifting air -------------------------------- */ + // big soft cloud shadows crawling across the ground + function drawCloudShadows(ctx, W, H, t) { + ctx.fillStyle = 'rgba(8,5,2,0.10)'; + for (var i = 0; i < 4; i++) { + var cx = ((i * 467 + t * (11 + i * 4)) % (W + 520)) - 260; + var cy = (i * 231 + Math.sin(t * 0.05 + i) * 60) % (H + 300); + ell(ctx, cx, cy - 150, 190 + i * 34, 74 + i * 12, i * 0.6); + ctx.fill(); + ell(ctx, cx + 120, cy - 110, 110 + i * 20, 48 + i * 9, i * 0.6); + ctx.fill(); + } + } + + // a vulture's shadow gliding across the arena now and then + function drawVultureShadow(ctx, W, H, t) { + var T = 26; + var p = (t % T) / T; + if (p > 0.32) return; // visible only ~8s per cycle + var q = p / 0.32; + var vx = -140 + q * (W + 280); + var vy = H * 0.3 + Math.sin(q * Math.PI) * H * 0.25; + var sc = 1.6 + q * 0.8; + ctx.fillStyle = 'rgba(8,5,2,0.16)'; + ell(ctx, vx, vy, 30 * sc, 10 * sc, 0.06); + ctx.fill(); + // wing beats: shadow width pulses + ctx.fillStyle = 'rgba(8,5,2,0.13)'; + ell(ctx, vx - 14 * sc, vy - 3, 14 * sc, 5 * sc, -0.25); + ctx.fill(); + ell(ctx, vx + 14 * sc, vy - 3, 14 * sc, 5 * sc, 0.25); + ctx.fill(); + } + + // floating ash / pollen motes catching the light + function drawAshMotes(ctx, W, H, t) { + for (var i = 0; i < 26; i++) { + var sd = i * 173.3; + var mx = (sd * 7.13 + t * (8 + (i % 5) * 4)) % (W + 40) - 20; + var my = (sd * 3.77 + Math.sin(t * 0.7 + i) * 24 + i * 31) % (H + 40) - 20; + var tw = 0.5 + 0.5 * Math.sin(t * 2.4 + i * 1.9); + ctx.globalAlpha = 0.10 + tw * 0.16; + ctx.fillStyle = i % 3 === 0 ? '#ffb96a' : '#d8ccb0'; + ell(ctx, mx, my, 1.6 + (i % 3) * 0.5, 1.6 + (i % 3) * 0.5, 0); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + /* ---------------- tropical rain -------------------------------------- */ + // Stateless streak field + ground splashes; intensity 0..1. + function drawRain(ctx, W, H, t, intensity) { + var n = Math.floor(72 * (intensity || 1)); + ctx.strokeStyle = 'rgba(178,198,235,0.34)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); + for (var i = 0; i < n; i++) { + var sd = i * 97.31; + var x = ((sd * 13.7 + t * (360 + (i % 40) * 9)) % (W + 180)) - 90; + var y = ((sd * 29.17 + t * 760) % (H + 90)) - 45; + var len = 15 + (i % 12); + ctx.moveTo(x, y); + ctx.lineTo(x - len * 0.28, y + len); + } + ctx.stroke(); + // splashes near the ground + ctx.fillStyle = 'rgba(190,210,240,0.16)'; + for (var sp4 = 0; sp4 < 16; sp4++) { + var sd2 = sp4 * 61.7; + var sx = ((sd2 * 17.3 + t * 300) % (W + 60)) - 30; + var sy = H - 26 - ((sd2 * 7.9) % 40); + var pr3 = 1.5 + ((t * 6 + sp4) % 1) * 2.5; + ell(ctx, sx, sy, pr3 * 1.7, pr3 * 0.7, 0); + ctx.fill(); + } + // wet air tint + ctx.fillStyle = 'rgba(140,168,215,0.05)'; + ctx.fillRect(0, 0, W, H); + } + + /* ---------------- nightfall ----------------------------------------- */ + // Darkness floods the valley on a 60s cycle; warm holes are punched out + // around every fire source so the tribe's flames truly matter at night. + function drawNight(ctx, W, H, g) { + var dk = g.darkness || 0; + if (dk <= 0.02) return; + + // One even veil over the whole valley. No punched holes, no light + // pools — those always read as big circles on open ground. + ctx.fillStyle = 'rgba(9,11,26,' + (dk * 0.85).toFixed(3) + ')'; + ctx.fillRect(0, 0, W, H); + + // Only TIGHT halos hugging actual flames — never floor-wide discs. + function flameGlow(x, y, r, flickerT, seed) { + var rr = r + Math.sin(flickerT * 9 + seed) * 6; + var gr = ctx.createRadialGradient(x, y, 0, x, y, rr); + gr.addColorStop(0, 'rgba(255,170,80,' + (0.34 * dk / 0.74).toFixed(3) + ')'); + gr.addColorStop(0.45, 'rgba(255,140,60,' + (0.16 * dk / 0.74).toFixed(3) + ')'); + gr.addColorStop(1, 'rgba(255,120,50,0)'); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = gr; + ctx.fillRect(x - rr, y - rr, rr * 2, rr * 2); + ctx.globalCompositeOperation = 'source-over'; + } + + // campfire crown + flameGlow(W / 2, H / 2 - 12, 95, g.time, 0); + // totem flames + if (g.decor && g.decor.totems) { + for (var ti = 0; ti < g.decor.totems.length; ti++) { + var tt = g.decor.totems[ti]; + flameGlow(tt.x, tt.y - 40, 42, g.time, tt.x); + } + } + // the hero's torch head + if (g.player && g.player.alive && g.torchGlow !== false) { + var tx2 = g.player.x + Math.cos((g.player.aim || 0) - 0.5) * 16; + var ty2 = g.player.y + Math.sin((g.player.aim || 0) - 0.5) * 16 - 14; + flameGlow(tx2, ty2, 52, g.time, 7.7); + } + } + + /* ---------------- attract-mode dusk scene (menu backdrop) ----------- */ + function drawMenuScene(ctx, w, h, t) { + // vivid dusk sky + var sky = ctx.createLinearGradient(0, 0, 0, h); + sky.addColorStop(0, '#221033'); + sky.addColorStop(0.38, '#542410'); + sky.addColorStop(0.66, '#b4581c'); + sky.addColorStop(0.82, '#d97a26'); + sky.addColorStop(1, '#241206'); + ctx.fillStyle = sky; + ctx.fillRect(0, 0, w, h); + + // first stars twinkling in the purple band + for (var st4 = 0; st4 < 46; st4++) { + var sx4 = ((st4 * 211.7) % w + w) % w; + var sy4 = ((st4 * 89.3) % (h * 0.34)); + var tw4 = 0.35 + 0.65 * Math.abs(Math.sin(t * 2.6 + st4 * 1.71)); + ctx.globalAlpha = tw4; + ctx.fillStyle = '#ffe9c9'; + ctx.fillRect(sx4, sy4, 1.8, 1.8); + } + ctx.globalAlpha = 1; + + // low blood sun with wide halo + var sunX = w * 0.72, sunY = h * 0.60; + var halo = ctx.createRadialGradient(sunX, sunY, h * 0.02, sunX, sunY, h * 0.30); + halo.addColorStop(0, 'rgba(255,200,120,0.55)'); + halo.addColorStop(0.5, 'rgba(255,150,70,0.22)'); + halo.addColorStop(1, 'rgba(255,120,50,0)'); + ctx.fillStyle = halo; + ctx.fillRect(sunX - h * 0.32, sunY - h * 0.32, h * 0.64, h * 0.64); + ctx.fillStyle = '#ffd98f'; + ell(ctx, sunX, sunY, h * 0.085, h * 0.085, 0); + ctx.fill(); + ctx.fillStyle = 'rgba(255,240,210,0.85)'; + ell(ctx, sunX - h * 0.02, sunY - h * 0.02, h * 0.05, h * 0.05, 0); + ctx.fill(); + + // far volcano, smoking + ctx.fillStyle = '#160c05'; + tri(ctx, w * 0.80, h * 0.66, w * 0.97, h * 0.66, w * 0.885, h * 0.30); + ctx.fill(); + ctx.fillStyle = 'rgba(255,110,50,' + (0.75 + 0.25 * Math.sin(t * 2.2)).toFixed(2) + ')'; + tri(ctx, w * 0.862, h * 0.350, w * 0.908, h * 0.350, w * 0.885, h * 0.308); + ctx.fill(); + for (var em = 0; em < 5; em++) { + var cyc = (t * 0.5 + em / 5) % 1; + ctx.globalAlpha = (1 - cyc) * 0.7; + ctx.fillStyle = '#ff8a3d'; + ell(ctx, + w * 0.885 + Math.sin(t * 2 + em * 2.1) * 14 * cyc, + h * 0.315 - cyc * h * 0.22, 2.2, 2.2, 0); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // mid-ground plain band with a warm horizon rim so silhouettes pop + ctx.fillStyle = '#2a1a0e'; + ctx.fillRect(0, h * 0.64, w, h); + ctx.fillStyle = 'rgba(255,150,70,0.30)'; + ctx.fillRect(0, h * 0.64, w, 5); + + /* every creature is a dark shape RIM-LIT by the sunset — visible! */ + function rimPath(fillCol, strokeCol, lw) { + ctx.fillStyle = fillCol; + ctx.fill(); + if (strokeCol) { + ctx.strokeStyle = strokeCol; + ctx.lineWidth = lw || 2; + ctx.stroke(); + } + } + + // ---- distant brachiosaurus against the bright sky, swaying slowly ---- + var bx = w * 0.15 + Math.sin(t * 0.11) * 12; + ctx.save(); + ctx.translate(bx, h * 0.665); + ctx.beginPath(); + ctx.moveTo(-64, 0); // tail tip on the ground + ctx.quadraticCurveTo(-38, -10, -22, -42); // up the back + ctx.quadraticCurveTo(-14, -66, 5, -74); // neck rise + ctx.quadraticCurveTo(15, -79, 19, -98 + Math.sin(t * 0.5) * 4); // small head high + ctx.lineTo(26, -95); + ctx.quadraticCurveTo(21, -70, 28, -55); // neck front + ctx.quadraticCurveTo(43, -24, 50, 0); // chest to ground + ctx.closePath(); + rimPath('#1c1108', 'rgba(255,150,70,0.85)', 2.4); + // legs (rim-lit too) + ctx.fillStyle = '#1c1108'; + ctx.strokeStyle = 'rgba(255,150,70,0.55)'; + ctx.lineWidth = 1.6; + ctx.fillRect(-33, -18, 9, 18); ctx.strokeRect(-33, -18, 9, 18); + ctx.fillRect(21, -18, 9, 18); ctx.strokeRect(21, -18, 9, 18); + ctx.restore(); + + // ---- T-REX stalking toward the sun — the star of the menu ---- + var trx = w * 0.47 + Math.sin(t * 0.23) * 16; + ctx.save(); + ctx.translate(trx, h * 0.685); + ctx.beginPath(); + ctx.moveTo(-78, 0); // heavy tail on the ground + ctx.quadraticCurveTo(-46, -16, -26, -38); // tail up to hips + ctx.quadraticCurveTo(-16, -52, -2, -56); // back + ctx.quadraticCurveTo(10, -60, 18, -72); // neck up + ctx.quadraticCurveTo(24, -84, 40, -88); // big square head + ctx.lineTo(44, -80); // brow dip + ctx.lineTo(38, -76); + ctx.lineTo(46, -68); // open upper jaw + ctx.quadraticCurveTo(30, -62, 22, -60); // jaw hinge + ctx.quadraticCurveTo(20, -48, 14, -34); // chest down + ctx.quadraticCurveTo(10, -26, 12, -16); // thigh mass + ctx.lineTo(20, 0); // standing leg + ctx.lineTo(6, 0); + ctx.lineTo(2, -14); + ctx.quadraticCurveTo(-8, -20, -14, -12); // belly + ctx.lineTo(-10, 0); // far leg + ctx.lineTo(-22, 0); + ctx.quadraticCurveTo(-30, -18, -40, -14); // rear + ctx.closePath(); + rimPath('#160c06', 'rgba(255,140,60,0.9)', 2.6); + // tiny fierce arm + ctx.strokeStyle = 'rgba(255,150,70,0.8)'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(14, -40); ctx.lineTo(24, -36); ctx.lineTo(22, -30); ctx.stroke(); + ctx.lineCap = 'butt'; + // eye glint + ctx.fillStyle = '#ffd98f'; + ctx.fillRect(32, -82, 3, 3); + ctx.restore(); + + // ---- raptor pack: bigger, warmer, clearly readable ---- + for (var rp = 0; rp < 3; rp++) { + var rx = ((t * (52 + rp * 11)) % (w + 320)) - 160; + var ry = h * (0.875 + rp * 0.035) + Math.abs(Math.sin(t * 9 + rp * 1.4)) * -5; + var rs = (1.35 + rp * 0.22); + ctx.save(); + ctx.translate(rx, ry); + ctx.scale(rs, rs); + ctx.beginPath(); + ctx.moveTo(-30, 0); + ctx.quadraticCurveTo(-16, -8, -7, -15); + ctx.quadraticCurveTo(5, -22, 12, -20); // body + ctx.quadraticCurveTo(19, -26, 24, -32); // neck + ctx.quadraticCurveTo(31, -35, 33, -30); // head + ctx.lineTo(26, -25); + ctx.quadraticCurveTo(17, -14, 15, 0); + ctx.closePath(); + rimPath(rp === 1 ? '#2a1509' : '#201006', 'rgba(255,130,45,' + (0.75 + rp * 0.08).toFixed(2) + ')', 1.8); + ctx.restore(); + } + + // foreground grass blades, swaying + ctx.strokeStyle = '#0a0603'; + ctx.lineWidth = 3.4; + ctx.lineCap = 'round'; + for (var gb = 0; gb < Math.floor(w / 26); gb++) { + var gx = gb * 26 + (gb % 3) * 7; + var gh = 22 + (gb % 4) * 9; + var sw2 = Math.sin(t * 1.6 + gb) * 5; + ctx.beginPath(); + ctx.moveTo(gx, h); + ctx.quadraticCurveTo(gx + sw2 * 0.4, h - gh * 0.6, gx + sw2, h - gh); + ctx.stroke(); + } + ctx.lineCap = 'butt'; + + // vignette so UI text stays readable + var vg2 = ctx.createRadialGradient(w / 2, h / 2, h * 0.30, w / 2, h / 2, h * 0.95); + vg2.addColorStop(0, 'rgba(0,0,0,0)'); + vg2.addColorStop(1, 'rgba(5,3,1,0.38)'); + ctx.fillStyle = vg2; + ctx.fillRect(0, 0, w, h); + } + + /* ================================================================ + Color helper + ================================================================ */ + function shade(hex, amt) { + var n = parseInt(hex.slice(1), 16); + var r = Math.max(0, Math.min(255, (n >> 16) + amt)); + var g2 = Math.max(0, Math.min(255, ((n >> 8) & 255) + amt)); + var b2 = Math.max(0, Math.min(255, (n & 255) + amt)); + return '#' + ((1 << 24) + (r << 16) + (g2 << 8) + b2).toString(16).slice(1); + } + + /* ================================================================ + Pickups & projectiles (thematic) + ================================================================ */ + function drawPickup(ctx, pk, blinkOn) { + if (!blinkOn) return; + ctx.save(); + ctx.translate(pk.x, pk.y + Math.sin(pk.t) * 2); + if (pk.kind === 'coin') { + // amber nugget + shadow(ctx, 0, 3, 8, 4); + ctx.fillStyle = '#ffb938'; + ctx.strokeStyle = '#a86a12'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(-7, -2); ctx.lineTo(-2, -7); ctx.lineTo(6, -5); + ctx.lineTo(7, 3); ctx.lineTo(1, 7); ctx.lineTo(-6, 4); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,230,0.85)'; + ell(ctx, -2, -3, 2, 1.4, -0.4); + ctx.fill(); + } else if (pk.kind === 'heal') { + // meat drumstick + shadow(ctx, 0, 3, 9, 4); + ctx.strokeStyle = BONE; + ctx.lineWidth = 4; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(2, 2); ctx.lineTo(9, 9); + ctx.stroke(); + ctx.fillStyle = BONE; + ell(ctx, 10, 10, 2.6, 2.6); + ctx.fill(); + ell(ctx, 12, 7, 2.2, 2.2); + ctx.fill(); + ctx.fillStyle = '#b3502e'; + ell(ctx, -1, -1, 7, 5.6, -0.7); + ctx.fill(); + ctx.fillStyle = 'rgba(255,220,190,0.35)'; + ell(ctx, -3, -3, 2.6, 1.6, -0.7); + ctx.fill(); + ctx.lineCap = 'butt'; + } else { + // fire bomb: round stone with burning fuse + shadow(ctx, 0, 3, 9, 4); + ctx.fillStyle = '#3c332a'; + ell(ctx, 0, 1, 8, 7); + ctx.fill(); + ctx.strokeStyle = '#211b14'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.strokeStyle = '#7a5230'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(2, -6); + ctx.quadraticCurveTo(6, -11, 4, -14); + ctx.stroke(); + var sp = 1 + Math.abs(Math.sin(pk.t * 6)) * 2; + ctx.fillStyle = '#ffb938'; + ell(ctx, 4, -15, sp, sp); + ctx.fill(); + ctx.fillStyle = '#e04b3a'; + ell(ctx, 4, -15, sp * 0.5, sp * 0.5); + ctx.fill(); + } + ctx.restore(); + } + + function drawBullet(ctx, b) { + var kind = b.kind || 'stone'; + var dx = b.vx, dy = b.vy; + var L = Math.hypot(dx, dy) || 1; + dx /= L; dy /= L; + // motion streak + ctx.strokeStyle = b.crit ? 'rgba(255,185,56,0.55)' : 'rgba(255,240,215,0.28)'; + ctx.lineWidth = b.size; + ctx.beginPath(); + ctx.moveTo(b.x - dx * b.size * 2.6, b.y - dy * b.size * 2.6); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(Math.atan2(dy, dx)); + + switch (kind) { + case 'blowpipe': // dart + ctx.fillStyle = '#caa96a'; + ctx.fillRect(-7, -1.1, 12, 2.2); + ctx.fillStyle = BONE; + tri(ctx, 5, -2.4, 10, 0, 5, 2.4); + ctx.fill(); + break; + case 'spears': // mini spear + ctx.fillStyle = '#8a6234'; + ctx.fillRect(-9, -1.6, 15, 3.2); + ctx.fillStyle = stoneCol; + tri(ctx, 6, -3, 13, 0, 6, 3); + ctx.fill(); + break; + case 'obelisk': // obsidian shard + ctx.fillStyle = '#3f3850'; + ctx.beginPath(); + ctx.moveTo(-8, -3); ctx.lineTo(9, 0); ctx.lineTo(-8, 3); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(199,146,255,0.5)'; + ctx.fillRect(-6, -0.8, 12, 1.6); + break; + case 'firebomb': // flame ball + ctx.fillStyle = '#ffb938'; + ell(ctx, 0, 0, b.size * 1.3, b.size); + ctx.fill(); + ctx.fillStyle = '#e04b3a'; + ell(ctx, -b.size * 0.4, 0, b.size * 0.7, b.size * 0.6); + ctx.fill(); + break; + case 'venom': // enemy spit + ctx.fillStyle = b.color || '#8ac74a'; + ell(ctx, 0, 0, b.size * 1.15, b.size * 0.85); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,220,0.4)'; + ell(ctx, -b.size * 0.5, -b.size * 0.3, b.size * 0.35, b.size * 0.25); + ctx.fill(); + break; + default: // sling stone + ctx.fillStyle = '#8f8578'; + ell(ctx, 0, 0, b.size * 1.05, b.size * 0.9); + ctx.fill(); + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ell(ctx, b.size * 0.3, b.size * 0.25, b.size * 0.35, b.size * 0.25); + ctx.fill(); + } + ctx.restore(); + } + + US.art = { + makeDecor: makeDecor, + drawGround: drawGround, + drawHuman: drawHuman, + drawRaptor: drawRaptor, + drawTriceratops: drawTriceratops, + drawStego: drawStego, + drawPachy: drawPachy, + drawDilo: drawDilo, + drawPtero: drawPtero, + drawRex: drawRex, + drawSpino: drawSpino, + drawGiga: drawGiga, + drawPickup: drawPickup, + drawBullet: drawBullet, + shade: shade, + drawCloudShadows: drawCloudShadows, + drawVultureShadow: drawVultureShadow, + drawAshMotes: drawAshMotes, + drawCampfire: drawCampfire, + drawNight: drawNight, + drawHeldWeapon: drawHeldWeapon, + drawRain: drawRain, + drawBiomeFx: drawBiomeFx, + BIOMES: BIOMES, + drawMenuScene: drawMenuScene + }; +})(); diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..f379f57 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,232 @@ +/* Neon Rampage — synthesized audio (WebAudio). All sounds generated at runtime. */ +(function () { + 'use strict'; + + var US = (window.US = window.US || {}); + var ctx = null; + var master = null; + var sfxGain = null; + var musicGain = null; + var musicTimer = null; + var step = 0; + var windSrc = null; // { src, lfo } + + function ensure() { + if (ctx) return true; + try { + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return false; + ctx = new AC(); + master = ctx.createGain(); + master.gain.value = 0.9; + master.connect(ctx.destination); + sfxGain = ctx.createGain(); + sfxGain.gain.value = US.save.get().settings.sfx; + sfxGain.connect(master); + musicGain = ctx.createGain(); + musicGain.gain.value = US.save.get().settings.music * 0.5; + musicGain.connect(master); + return true; + } catch (e) { return false; } + } + + function resume() { + if (!ensure()) return; + if (ctx.state === 'suspended') ctx.resume(); + } + + // Generic one-shot: oscillator with pitch slide + optional noise burst. + function tone(opts) { + if (!ensure() || ctx.state === 'suspended') { if (!resume()) return; } + var t = ctx.currentTime; + var o = ctx.createOscillator(); + var g = ctx.createGain(); + o.type = opts.type || 'square'; + o.frequency.setValueAtTime(opts.f0 || 440, t); + if (opts.f1) o.frequency.exponentialRampToValueAtTime(Math.max(20, opts.f1), t + (opts.dur || 0.15)); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(opts.vol || 0.2, t + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t + (opts.dur || 0.15)); + o.connect(g); g.connect(sfxGain); + o.start(t); o.stop(t + (opts.dur || 0.15) + 0.05); + + if (opts.noise) { + var len = Math.floor(ctx.sampleRate * (opts.noiseDur || 0.12)); + var buf = ctx.createBuffer(1, len, ctx.sampleRate); + var d = buf.getChannelData(0); + for (var i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + var src = ctx.createBufferSource(); + src.buffer = buf; + var ng = ctx.createGain(); + ng.gain.value = opts.noiseVol || 0.25; + src.connect(ng); ng.connect(sfxGain); + src.start(t); + } + } + + var SFX = { + shoot: function () { tone({ type: 'square', f0: 720, f1: 160, dur: 0.07, vol: 0.10 }); }, + shotgun: function () { tone({ type: 'sawtooth', f0: 300, f1: 90, dur: 0.16, vol: 0.18, noise: true, noiseVol: 0.2 }); }, + rail: function () { tone({ type: 'sawtooth', f0: 1400, f1: 200, dur: 0.22, vol: 0.14 }); }, + rocket: function () { tone({ type: 'triangle', f0: 220, f1: 60, dur: 0.3, vol: 0.2, noise: true }); }, + smg: function () { tone({ type: 'square', f0: 520, f1: 140, dur: 0.05, vol: 0.07 }); }, + hit: function () { tone({ type: 'triangle', f0: 300, f1: 120, dur: 0.05, vol: 0.08 }); }, + kill: function () { tone({ type: 'square', f0: 260, f1: 520, dur: 0.09, vol: 0.10 }); }, + explode: function () { tone({ type: 'sawtooth', f0: 130, f1: 40, dur: 0.4, vol: 0.28, noise: true, noiseVol: 0.35, noiseDur: 0.3 }); }, + hurt: function () { tone({ type: 'sawtooth', f0: 180, f1: 70, dur: 0.18, vol: 0.22 }); }, + pickup: function () { tone({ type: 'sine', f0: 620, f1: 1240, dur: 0.09, vol: 0.12 }); }, + heal: function () { tone({ type: 'sine', f0: 440, f1: 880, dur: 0.18, vol: 0.14 }); }, + card: function () { tone({ type: 'sine', f0: 500, f1: 1000, dur: 0.14, vol: 0.13 }); }, + onfire: function () { + [523, 659, 784, 1047].forEach(function (f, i) { + setTimeout(function () { tone({ type: 'square', f0: f, dur: 0.12, vol: 0.16 }); }, i * 70); + }); + }, + ult: function () { tone({ type: 'sawtooth', f0: 80, f1: 900, dur: 0.5, vol: 0.3, noise: true, noiseVol: 0.3 }); }, + bomb: function () { tone({ type: 'sawtooth', f0: 100, f1: 30, dur: 0.6, vol: 0.32, noise: true, noiseVol: 0.45, noiseDur: 0.5 }); }, + bossRoar:function () { tone({ type: 'sawtooth', f0: 90, f1: 45, dur: 0.7, vol: 0.3, noise: true, noiseVol: 0.2 }); }, + stomp: function (vol) { + var v = vol || 0.18; + tone({ type: 'sine', f0: 85, f1: 26, dur: 0.17, vol: v }); + tone({ type: 'triangle', f0: 55, f1: 22, dur: 0.24, vol: v * 0.7 }); + }, + ui: function () { tone({ type: 'sine', f0: 700, f1: 900, dur: 0.05, vol: 0.08 }); }, + buy: function () { [660, 880].forEach(function (f, i) { setTimeout(function () { tone({ type: 'sine', f0: f, dur: 0.1, vol: 0.12 }); }, i * 90); }); }, + lose: function () { [392, 330, 262, 196].forEach(function (f, i) { setTimeout(function () { tone({ type: 'triangle', f0: f, dur: 0.22, vol: 0.16 }); }, i * 160); }); }, + win: function () { [523, 659, 784, 1047, 1319].forEach(function (f, i) { setTimeout(function () { tone({ type: 'square', f0: f, dur: 0.15, vol: 0.14 }); }, i * 110); }); } + }; + + /* --- tiny procedural music loop: dark synthwave bass + hats --- */ + var BASS = [0, 0, 7, 0, 5, 5, 3, 7]; // semitone offsets from A1 + function note(semi) { return 55 * Math.pow(2, semi / 12); } + + function musicStep() { + if (!ctx || !musicOn) return; + var t = ctx.currentTime; + // bass + var o = ctx.createOscillator(); + var g = ctx.createGain(); + o.type = 'sawtooth'; + var root = BASS[Math.floor(step / 2) % BASS.length]; + var playingBoss = US.game && US.game.isBossWave && US.game.isBossWave(); + var mul = playingBoss ? Math.pow(2, 3 / 12) : 1; + o.frequency.value = note(root) * mul; + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.22, t + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.24); + o.connect(g); g.connect(musicGain); + o.start(t); o.stop(t + 0.3); + // hat every other step + if (step % 2 === 1) { + var len = Math.floor(ctx.sampleRate * 0.03); + var buf = ctx.createBuffer(1, len, ctx.sampleRate); + var d = buf.getChannelData(0); + for (var i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + var src = ctx.createBufferSource(); + src.buffer = buf; + var hg = ctx.createGain(); hg.gain.value = 0.05; + src.connect(hg); hg.connect(musicGain); + src.start(t); + } + step++; + } + + var musicOn = false; + + /* --- wind ambience: filtered brown noise with slow gusts --- */ + var ambGain = null; + + function startWind() { + if (!ctx || windSrc || !musicOn) return; + var len = ctx.sampleRate * 2; + var buf = ctx.createBuffer(1, len, ctx.sampleRate); + var d = buf.getChannelData(0); + var lastV = 0; + for (var i = 0; i < len; i++) { + var wn = Math.random() * 2 - 1; + lastV = (lastV + 0.02 * wn) / 1.02; // brown-ish noise + d[i] = lastV * 3.5; + } + var src = ctx.createBufferSource(); + src.buffer = buf; + src.loop = true; + var lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 300; + lp.Q.value = 0.7; + // gust LFO breathing on the filter + var lfo = ctx.createOscillator(); + lfo.frequency.value = 0.11; + var lfoG = ctx.createGain(); + lfoG.gain.value = 150; + lfo.connect(lfoG); + lfoG.connect(lp.frequency); + ambGain = ctx.createGain(); + ambGain.gain.value = 0; + src.connect(lp); + lp.connect(ambGain); + ambGain.connect(musicGain); + try { + ambGain.gain.linearRampToValueAtTime(0.5, ctx.currentTime + 2.5); + src.start(); + lfo.start(); + windSrc = { src: src, lfo: lfo }; + } catch (e) { /* autoplay quirks */ } + } + + function stopWind() { + if (!windSrc) return; + try { + ambGain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.4); + var w = windSrc; + setTimeout(function () { + try { w.src.stop(); w.lfo.stop(); } catch (e) {} + }, 450); + } catch (e) {} + windSrc = null; + } + + US.audio = { + resume: resume, + sfx: function (name) { + if (!ensure()) return; + sfxGain.gain.value = US.save.get().settings.sfx; + if (SFX[name]) SFX[name].apply(null, Array.prototype.slice.call(arguments, 1)); + }, + setVolumes: function () { + if (!ctx) return; + sfxGain.gain.value = US.save.get().settings.sfx; + musicGain.gain.value = US.save.get().settings.music * 0.5; + }, + startMusic: function () { + if (!ensure()) return; + resume(); + if (!musicTimer) { + musicOn = true; + musicStep(); + musicTimer = setInterval(musicStep, 250); + } + startWind(); + }, + stopMusic: function () { + musicOn = false; + if (musicTimer) { clearInterval(musicTimer); musicTimer = null; } + stopWind(); + }, + hoverTick: function () { + if (!ensure()) return; + tone({ type: 'sine', f0: 900, f1: 1100, dur: 0.035, vol: 0.05 }); + }, + thunder: function () { + if (!ensure()) return; + tone({ type: 'sawtooth', f0: 52, f1: 22, dur: 1.2, vol: 0.3, noise: true, noiseVol: 0.55, noiseDur: 1.3 }); + }, + distantRoar: function () { + if (!ensure()) return; + tone({ type: 'sawtooth', f0: 120, f1: 36, dur: 1.5, vol: 0.09 }); + setTimeout(function () { + tone({ type: 'triangle', f0: 70, f1: 28, dur: 1.9, vol: 0.07 }); + }, 180); + } + }; +})(); diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..84f6eba --- /dev/null +++ b/js/data.js @@ -0,0 +1,261 @@ +/* PRIMAL RAMPAGE — game data: weapons, heroes, skins, beasts, alphas, cards. + Theme: prehistoric tribe survival vs dinosaurs. Original content. + NOTE: ids stay stable so existing saved profiles keep working; + `kind` selects the drawn sprite in js/art.js */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + + /* ---------------- Weapons ---------------- */ + US.WEAPONS = { + pistol: { + id: 'pistol', name: 'Slingshot', price: 0, kind: 'slingshot', + dmg: 12, rof: 3.2, speed: 620, pellets: 1, spread: 0.04, pierce: 0, + color: '#c9bda6', size: 4, sfx: 'shoot' + }, + smg: { + id: 'smg', name: 'Bone Blowpipe', price: 400, kind: 'blowpipe', + dmg: 7, rof: 8.5, speed: 700, pellets: 1, spread: 0.13, pierce: 0, + color: '#caa96a', size: 3.5, sfx: 'smg' + }, + shotgun: { + id: 'shotgun', name: 'Spear Burst', price: 750, kind: 'spears', + dmg: 8, rof: 1.5, speed: 560, pellets: 6, spread: 0.38, pierce: 0, + color: '#a8814f', size: 4, sfx: 'shotgun', knock: 120 + }, + rail: { + id: 'rail', name: 'Obsidian Lance', price: 1200, kind: 'obelisk', + dmg: 46, rof: 1.1, speed: 1500, pellets: 1, spread: 0, pierce: 3, + color: '#7b6fa0', size: 5, sfx: 'rail' + }, + launcher: { + id: 'launcher', name: 'Fire Bomb Thrower', price: 1800, kind: 'firebomb', + dmg: 30, rof: 0.9, speed: 430, pellets: 1, spread: 0.05, pierce: 0, + color: '#e04b3a', size: 7, sfx: 'rocket', aoe: 90 + } + }; + + /* ---------------- Heroes ---------------- */ + US.HEROES = { + recruit: { + id: 'recruit', name: 'Raider', price: 0, + hp: 100, speed: 250, dmgMul: 1, + talent: '+0 — cân bằng / balanced', + tint: '#e04b3a' + }, + scout: { + id: 'scout', name: 'Trail Runner', price: 500, + hp: 75, speed: 320, dmgMul: 0.9, + talent: '+28% tốc độ / speed — mỏng máu / frail', + tint: '#58a55c' + }, + brute: { + id: 'brute', name: 'Bear-Clan Brute', price: 900, + hp: 160, speed: 205, dmgMul: 1.25, + talent: 'Trâu bò, đòn nặng, chậm / tanky, heavy, slow', + tint: '#ffb938' + }, + gunslinger: { + id: 'gunslinger', name: 'Spirit Hunter', price: 1400, + hp: 90, speed: 265, dmgMul: 1.05, + talent: '+15% chí mạng ×2.5 / crit chance', + crit: 0.15, critMul: 2.5, + tint: '#c56cf0' + } + }; + + /* ---------------- Skins (war-paint palettes) ---------------- */ + US.SKINS = { + default: { id: 'default', name: 'Clay', price: 0, body: '#e04b3a', trim: '#e8dcc4' }, + ember: { id: 'ember', name: 'Ember', price: 300, body: '#ff8a3d', trim: '#ffd9a0' }, + frost: { id: 'frost', name: 'Glacier', price: 300, body: '#9fd8ff', trim: '#e8fbff' }, + void: { id: 'void', name: 'Night Shade', price: 600, body: '#a86bff', trim: '#e2ccff' }, + gold: { id: 'gold', name: 'Sun Chief', price: 900, body: '#ffb938', trim: '#fff3c4' }, + crimson: { id: 'crimson', name: 'Blood Moon', price: 900, body: '#c22f45', trim: '#ffc2c8' }, + // --- premium tier, bought with Ngọn Lửa (hard currency) --- + ashfang: { id: 'ashfang', name: 'Ashfang Chief', price: 0, gems: 15, body: '#5a4a3c', trim: '#ff6b35', premium: true, + vi: 'Thủ Lĩnh Tro Tàn — hào quang núi lửa' }, + jade: { id: 'jade', name: 'Jade Serpent', price: 0, gems: 25, body: '#2fae7a', trim: '#d9ffe9', premium: true, + vi: 'Trăn Bích Ngọc — da rắn thần rừng' }, + solaris: { id: 'solaris', name: 'Solaris', price: 0, gems: 40, body: '#ffd24a', trim: '#fff8dd', premium: true, glow: true, + vi: 'Thái Dương — ánh sáng nguyên thủy' } + }; + + /* ---------------- Beasts ---------------- */ + // contact = dps on touch; value feeds heat/score; cost feeds wave budget + US.ENEMIES = { + grunt: { + id: 'grunt', art: 'raptor', hp: 30, speed: 95, radius: 14, contact: 12, value: 1, cost: 10, + coin: [1, 2], color: '#5f8f3e', score: 10, fromWave: 1 + }, + runner: { + id: 'runner', art: 'compy', hp: 16, speed: 185, radius: 11, contact: 9, value: 1, cost: 12, + coin: [1, 2], color: '#d98e2b', score: 12, fromWave: 2 + }, + spitter: { + id: 'spitter', art: 'dilo', hp: 34, speed: 70, radius: 13, contact: 8, value: 2, cost: 22, + coin: [2, 3], color: '#8ac74a', score: 20, fromWave: 3, + ranged: { dmg: 10, speed: 240, rof: 1.6, range: 380 } + }, + tank: { + id: 'tank', art: 'trike', hp: 130, speed: 55, radius: 22, contact: 22, value: 3, cost: 40, + coin: [4, 7], color: '#8a7a5c', score: 35, fromWave: 4 + }, + stego: { + id: 'stego', art: 'stego', hp: 90, speed: 60, radius: 19, contact: 18, value: 2, cost: 34, + coin: [3, 5], color: '#6f7d44', score: 30, fromWave: 7 + }, + pachy: { + id: 'pachy', art: 'pachy', hp: 45, speed: 120, radius: 13, contact: 14, value: 2, cost: 26, + coin: [2, 4], color: '#b5893e', score: 28, fromWave: 5, + dash: { speed: 340, dur: 0.45, cd: 2.6 } + }, + splitter: { + id: 'splitter', art: 'ptero', hp: 60, speed: 85, radius: 18, contact: 14, value: 2, cost: 30, + coin: [2, 4], color: '#7ba3a8', score: 25, fromWave: 6, + splits: { into: 'mini', count: 3 } + }, + mini: { + id: 'mini', art: 'pteroChick', hp: 14, speed: 165, radius: 9, contact: 7, value: 0.5, cost: 0, + coin: [1, 1], color: '#93b7bb', score: 6, noSpawn: true + }, + eliteGrunt: { + id: 'eliteGrunt', art: 'alphaRaptor', hp: 110, speed: 105, radius: 17, contact: 16, value: 3, cost: 45, + coin: [6, 9], color: '#b0432e', score: 50, elite: true, fromWave: 8 + } + }; + + US.BOSSES = [ + { + id: 'bruiser', art: 'rex', name: 'REX THE ELDER', + hp: 1600, speed: 80, radius: 44, contact: 30, + color: '#7e4022', score: 500, coin: [40, 60], + patterns: ['charge', 'spawn'] + }, + { + id: 'artillery', art: 'spino', name: 'SPINO OF THE REEDS', + hp: 2000, speed: 55, radius: 46, contact: 24, + color: '#4f6f52', score: 650, coin: [50, 75], + patterns: ['radial', 'aimed', 'spiral'] + }, + { + id: 'hydra', art: 'giga', name: 'GIGA BROODMOTHER', + hp: 2600, speed: 70, radius: 42, contact: 26, + color: '#55514e', score: 800, coin: [60, 90], + patterns: ['split', 'charge'] + } + ]; + + /* ---------------- Upgrade cards ---------------- */ + // apply(p) mutates player stats; stack limits where noted. + US.CARDS = [ + { id: 'dmg', rarity: 'common', max: 99, icon: '🦷', title: 'Sharpened Fangs', desc: '+25% damage / sát thương', apply: function (s) { s.dmgMul += 0.25; } }, + { id: 'rof', rarity: 'common', max: 99, icon: '🖐', title: 'Quick Hands', desc: '+20% fire rate / tốc độ bắn', apply: function (s) { s.rofMul += 0.2; } }, + { id: 'hp', rarity: 'common', max: 99, icon: '🦴', title: 'Thick Hide', desc: '+25 max HP & heal 50 / hồi 50', apply: function (s) { s.maxHp += 25; }, onPick: function (pl) { pl.hp = Math.min(pl.stats.maxHp, pl.hp + 50); } }, + { id: 'speed', rarity: 'common', max: 5, icon: '👣', title: 'Fleet Foot', desc: '+15% move speed / di chuyển', apply: function (s) { s.speedMul += 0.15; } }, + { id: 'multi', rarity: 'epic', max: 4, icon: '🔱', title: 'Twin Arms', desc: '+1 projectile / thêm 1 phát', apply: function (s) { s.pellets += 1; } }, + { id: 'pierce', rarity: 'rare', max: 4, icon: '➶', title: 'Skewer', desc: 'Bullets pierce +1 / xuyên thêm 1', apply: function (s) { s.pierce += 1; } }, + { id: 'crit', rarity: 'rare', max: 6, icon: '✦', title: 'Deadeye', desc: '+10% crit chance / chí mạng', apply: function (s) { s.crit += 0.10; } }, + { id: 'critdmg', rarity: 'rare', max: 4, icon: '✸', title: 'Executioner', desc: '+50% crit damage / sát thương chí mạng', apply: function (s) { s.critMul += 0.5; } }, + { id: 'leech', rarity: 'epic', max: 4, icon: '🩸', title: 'Red Harvest', desc: 'Heal 3% of damage dealt / hút máu', apply: function (s) { s.lifesteal += 0.03; } }, + { id: 'bspd', rarity: 'common', max: 4, icon: '💪', title: 'Strong Arm', desc: '+30% bullet speed / đạn bay nhanh', apply: function (s) { s.bulletSpeedMul += 0.3; } }, + { id: 'magnet', rarity: 'common', max: 4, icon: '🧲', title: 'Hoarder', desc: '+80 pickup range / hút vật phẩm', apply: function (s) { s.magnet += 80; } }, + { id: 'boom', rarity: 'epic', max: 3, icon: '🔥', title: 'Fire Bombs', desc: 'Bullets explode on hit / đạn nổ', apply: function (s) { s.explosive += 1; } }, + { id: 'rico', rarity: 'epic', max: 2, icon: '🔄', title: 'Ricochet', desc: 'Bullets bounce to a new target / đạn nảy', apply: function (s) { s.ricochet += 1; } }, + { id: 'bomb', rarity: 'rare', max: 99, icon: '💣', title: 'Bundle of Fire', desc: '+2 bombs now / nhận 2 bom', apply: function () {}, onPick: function (pl) { pl.bombs += 2; } }, + { id: 'ultcharge', rarity: 'rare', max: 4, icon: '🌟', title: 'Ancestor Spirit', desc: '+40% ultimate charge rate / nạp chiêu nhanh', apply: function (s) { s.ultRate += 0.4; } }, + { id: 'ultnow', rarity: 'epic', max: 99, icon: '☄', title: 'Spirit Call', desc: 'Ultimate fully charged / chiêu cuối đầy', apply: function () {}, onPick: function (pl) { pl.ult = 100; } }, + { id: 'greed', rarity: 'common', max: 4, icon: '🪙', title: 'Shiny Things', desc: '+35% coins earned / nhiều vàng hơn', apply: function (s) { s.coinMul += 0.35; } }, + { id: 'regen', rarity: 'rare', max: 4, icon: '🌿', title: "Healer's Ritual", desc: 'Regenerate +1.5 HP/s / hồi máu', apply: function (s) { s.regen += 1.5; } }, + { id: 'thorns', rarity: 'rare', max: 3, icon: '🌵', title: 'Spiky Hide', desc: 'Damages enemies that touch you / gai phản đòn', apply: function (s) { s.thorns += 25; } }, + { id: 'bigbullets', rarity: 'rare', max: 3, icon: '⬤', title: 'Boulder Shots', desc: '+40% bullet size & +10% damage / đạn to', apply: function (s) { s.bulletSizeMul += 0.4; s.dmgMul += 0.1; } } + ]; + + /* ---------------- Permanent upgrades (spend amber, keep forever) ------ */ + US.UPGRADES = { + hp: { id: 'hp', max: 5, base: 120, per: '+10 máu tối đa / cấp', + vi: 'Tim Sói Đầu', en: 'Wolf-Heart', apply: function (st, lv) { st.maxHp += lv * 10; } }, + dmg: { id: 'dmg', max: 5, base: 180, per: '+6% sát thương / cấp', + vi: 'Nọc Rắn Hổ', en: 'Viper Fangs', apply: function (st, lv) { st.dmgMul += lv * 0.06; } }, + speed: { id: 'speed', max: 5, base: 150, per: '+3% tốc chạy / cấp', + vi: 'Gót Gió', en: 'Wind Heels', apply: function (st, lv) { st.speedMul += lv * 0.03; } }, + ult: { id: 'ult', max: 5, base: 200, per: '+10% hồi chiêu ultimate / cấp', + vi: 'Mặt Trời Trẻ', en: 'Young Sun', apply: function (st, lv) { st.ultRate = (st.ultRate || 1) + lv * 0.1; } }, + coin: { id: 'coin', max: 5, base: 160, per: '+5% hổ phách nhặt được / cấp', + vi: 'Mắt Kền Kền', en: 'Vulture Eye', apply: function (st, lv) { st.coinMul = (st.coinMul || 1) + lv * 0.05; } }, + bombs: { id: 'bombs', max: 2, base: 400, per: '+1 bom khởi đầu / cấp', + vi: 'Túi Phù Thủy', en: 'Shaman Pouch', apply: function (st, lv) { st.startBombs = (st.startBombs || 0) + lv; } } + }; + + /* ---------------- Bestiary: every beast of the valley ------------------ */ + // goal kills to complete an entry; reward paid in ngọn lửa (gems) + US.BESTIARY = { + grunt: { name: { en: 'Green Raptor', vi: 'Raptor Xanh Lục' }, goal: 15, gem: 1, + lore: { en: 'Fast pack hunter. Never alone.', vi: 'Săn theo bầy, nhanh như gió. Không bao giờ đi một mình.' } }, + runner: { name: { en: 'Compy Scout', vi: 'Compy Dothám' }, goal: 20, gem: 1, + lore: { en: 'Small, curious, bites your ankle.', vi: 'Nhỏ mà tinh, chuyên cắn gót chân.' } }, + spitter: { name: { en: 'Dilophosaurus', vi: 'Khủng Long Có Mào' }, goal: 12, gem: 1, + lore: { en: 'Spits venom from beyond spear range.', vi: 'Phun nọc từ xa hơn tầm giáo ném.' } }, + tank: { name: { en: 'Triceratops', vi: 'Triceratops' }, goal: 8, gem: 2, + lore: { en: 'A living battering ram with three horns.', vi: 'Con đập sống ba sừng, tường thành di động.' } }, + stego: { name: { en: 'Stegosaurus', vi: 'Stegosaurus' }, goal: 8, gem: 2, + lore: { en: 'Its tail spikes end arguments.', vi: 'Đuôi chùy bốn gai — kết thúc mọi tranh luận.' } }, + pachy: { name: { en: 'Pachycephalosaurus', vi: 'Khủng Long Đầu Đội' }, goal: 10, gem: 2, + lore: { en: 'Charges head-first. The skull is a weapon.', vi: 'Lao đầu như búa đá — hộp sọ là vũ khí.' } }, + splitter: { name: { en: 'Pteranodon', vi: 'Pteranodon' }, goal: 10, gem: 2, + lore: { en: 'Dive-bombers. Break one, three scream back.', vi: 'Chúng lao xuống từ trời. Đánh một, ba con gào lại.' } }, + mini: { name: { en: 'Ptero Chick', vi: 'P Con' }, goal: 25, gem: 1, + lore: { en: 'Hatched angry. Flies angrier.', vi: 'Nở ra đã giận, bay lên còn giận hơn.' } }, + eliteGrunt: { name: { en: 'Alpha Raptor', vi: 'Raptor Đầu Đàn' }, goal: 6, gem: 3, + lore: { en: 'Scarred leader of the pack.', vi: 'Thủ lĩnh đầy sẹo của bầy xanh.' } }, + bruiser: { name: { en: 'REX THE ELDER', vi: 'REX GIÀ' }, goal: 3, gem: 5, + lore: { en: 'The valley remembers his footsteps.', vi: 'Cả thung lũng nhớ tiếng bước chân của hắn.' } }, + artillery: { name: { en: 'SPINO THE TIDE', vi: 'SPINO THỦY TRIỀU' }, goal: 3, gem: 5, + lore: { en: 'A sail that blocks out the sun.', vi: 'Chiếc buồng chặn cả mặt trời.' } }, + hydra: { name: { en: 'GIGA THE BROOD', vi: 'GIGA MA VẬT CHÚNG' }, goal: 3, gem: 8, + lore: { en: 'Kill it and it multiplies. Kill those too.', vi: 'Giết nó thì nó sinh thêm. Giết nốt những đứa sinh ra.' } } + }; + + /* ---------------- Daily quests (pick 3/day by date seed) --------------- */ + // type hooks fired by the game: kill, collect, wave, ult, boss + US.QUEST_POOL = [ + { id: 'kill30', type: 'kill', goal: 30, gem: 1, coins: 80, + text: { en: 'Slay 30 beasts', vi: 'Hạ 30 con thú' } }, + { id: 'kill80', type: 'kill', goal: 80, gem: 2, coins: 200, + text: { en: 'Slay 80 beasts', vi: 'Hạ 80 con thú' } }, + { id: 'amber12', type: 'collect', goal: 12, gem: 1, coins: 60, + text: { en: 'Collect 12 amber nuggets', vi: 'Nhặt 12 viên hổ phách' } }, + { id: 'wave5', type: 'wave', goal: 5, gem: 2, coins: 150, + text: { en: 'Reach wave 5 in one run', vi: 'Đạt wave 5 trong một trận' } }, + { id: 'ult3', type: 'ult', goal: 3, gem: 1, coins: 70, + text: { en: 'Unleash 3 ultimates', vi: 'Phóng 3 lần chiêu cuối' } }, + { id: 'boss1', type: 'boss', goal: 1, gem: 3, coins: 250, + text: { en: 'Defeat a boss', vi: 'Hạ 1 trùm khủng long' } } + ]; + + /* ---------------- Achievements ----------------------------------------- */ + US.ACHIEVEMENTS = { + firstBlood: { gem: 1, test: function (sv, g) { return sv.stats.totalKills >= 1; }, + text: { en: 'First Blood — slay your first beast', vi: 'Máu Đầu — hạ con thú đầu tiên' } }, + slayer100: { gem: 2, test: function (sv) { return sv.stats.totalKills >= 100; }, + text: { en: 'Beast Slayer — 100 kills total', vi: 'Thợ Săn — tổng 100 mạng' } }, + slayer500: { gem: 4, test: function (sv) { return sv.stats.totalKills >= 500; }, + text: { en: 'Apex Hunter — 500 kills total', vi: 'Thợ Săn Bậc Nhất — tổng 500 mạng' } }, + wave5: { gem: 1, test: function (sv) { return sv.bestWave >= 5; }, + text: { en: 'Face the Elder — reach wave 5', vi: 'Đối mặt Cố Nhân — đạt wave 5' } }, + wave10: { gem: 2, test: function (sv) { return sv.bestWave >= 10; }, + text: { en: 'Deep Jungle — reach wave 10', vi: 'Rừng Sâu — đạt wave 10' } }, + wave25: { gem: 5, test: function (sv) { return sv.bestWave >= 25; }, + text: { en: 'Valley Conqueror — finish wave 25', vi: 'Chinh Phục Thung Lũng — qua wave 25' } }, + combo20: { gem: 2, test: function (g) { return g && g.bestCombo >= 20; }, + text: { en: 'On Fire Master — combo ×20', vi: 'Bậc Thầy Bùng Cháy — combo ×20' } }, + rich: { gem: 2, test: function (sv) { return sv.stats.totalAmber >= 300; }, + text: { en: 'Amber Hoard — gather 300 amber', vi: 'Kho Hổ Phách — gom 300 hổ phách' } }, + petParent: { gem: 3, test: function (sv) { return !!sv.pet; }, + text: { en: 'Pack Parent — hatch a raptor egg', vi: 'Cha Mẹ Bầy Đàn — ấp trứng raptor' } }, + endless10: { gem: 4, test: function (sv) { return sv.bestWave >= 30; }, + text: { en: 'Beyond the Endless — wave 30+', vi: 'Vượt Vô Tận — wave 30+' } } + }; + + US.RARITY_COLOR = { common: '#9c8a72', rare: '#58a55c', epic: '#ff8a3d' }; +})(); diff --git a/js/entities.js b/js/entities.js new file mode 100644 index 0000000..dc78537 --- /dev/null +++ b/js/entities.js @@ -0,0 +1,442 @@ +/* Neon Rampage — entities: player, enemies, bullets, pickups, particles. */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + var R = US.util.rand; + + function hooks() { return US.entities._hooks || {}; } + + /* ---------------- Player ---------------- */ + function Player(opts) { + var hero = opts.hero; + this.x = opts.x; this.y = opts.y; + this.vx = 0; this.vy = 0; + this.radius = 15; + this.heroId = hero.id; + this.skin = opts.skin; + this.stats = { + maxHp: hero.hp, + speedMul: 1, + dmgMul: hero.dmgMul, + rofMul: 1, + pellets: 0, // extra projectiles from cards + pierce: 0, + crit: hero.crit || 0.05, + critMul: hero.critMul || 2, + lifesteal: 0, + bulletSpeedMul: 1, + bulletSizeMul: 1, + magnet: 90, + explosive: 0, + ricochet: 0, + coinMul: 1, + regen: 0, + thorns: 0, + ultRate: 1, + startBombs: 0 + }; + // permanent amber upgrades persist across every run + var UPS = US.UPGRADES || {}; + var svUp = (US.save && US.save.get().upgrades) || {}; + Object.keys(UPS).forEach(function (k) { + var lv = svUp[k] | 0; + if (lv > 0 && UPS[k].apply) UPS[k].apply(this.stats, lv); + }, this); + this.hp = this.stats.maxHp; + this.speedBase = hero.speed; + this.weapon = US.WEAPONS[opts.weaponId] || US.WEAPONS.pistol; + this.cooldown = 0; + this.bombs = opts.bombs || 0; + this.ult = 0; + this.invuln = 1.2; // spawn protection + this.aim = 0; + this.firing = false; + this.cards = []; + this.alive = true; + } + + Player.prototype.takeCard = function (card) { + card.apply(this.stats); + if (card.onPick) card.onPick(this); + this.cards.push(card.id); + }; + + Player.prototype.dpsStats = function () { + var s = this.stats, w = this.weapon; + var gg = US.game && US.game.get ? US.game.get() : null; + var fire = !!(gg && gg.onFire); + return { + dmg: w.dmg * s.dmgMul * (fire ? 2 : 1), + rof: w.rof * s.rofMul * (fire ? 1.4 : 1), + pellets: w.pellets + s.pellets + }; + }; + + /* ---------------- Enemy ---------------- */ + function Enemy(def, x, y, mods) { + mods = mods || {}; + this.def = def; + this.id = def.id; + this.x = x; this.y = y; + this.vx = 0; this.vy = 0; + this.radius = def.radius * (mods.size || 1); + this.maxHp = def.hp * (mods.hp || 1); + this.hp = this.maxHp; + this.speed = def.speed * (mods.speed || 1) * R(0.9, 1.12); + this.contactDmg = def.contact * (mods.dmg || 1); + this.elite = !!mods.elite; + this.color = this.elite ? '#ff2e50' : def.color; + this.hitFlash = 0; + this.slowT = 0; + this.touchCd = 0; + this.shootCd = def.ranged ? R(0.5, 1.6) : 0; + this.wobble = Math.random() * Math.PI * 2; + this.face = 0; + this.seed = Math.random() * 10; + // boss state + this.isBoss = !!mods.isBoss; + this.patterns = def.patterns || []; + this.patCd = 2; + this.charging = 0; + this.chargeDir = { x: 0, y: 0 }; + this.splitStage = mods.splitStage || 0; + this.spawnAnim = 0.6; + this.alive = true; + } + + Enemy.prototype.update = function (g, dt) { + if (this.spawnAnim > 0) { + this.spawnAnim -= dt; + return; // brief spawn-in grace (drawn as growing ring) + } + var p = g.player; + var dx = p.x - this.x, dy = p.y - this.y; + var d = Math.hypot(dx, dy) || 1; + var nx = dx / d, ny = dy / d; + var sp = this.speed * (this.slowT > 0 ? 0.45 : 1); + // tar pits bog everything down + if (hooks().tarSlow && hooks().tarSlow(this.x, this.y)) sp *= 0.55; + // beasts hunt with confidence under moonlight + if (hooks().nightFast && hooks().nightFast()) sp *= 1.08; + if (this.slowT > 0) this.slowT -= dt; + + var ranged = this.def.ranged; + if (ranged && d < ranged.range && d > 120) { + // strafe & shoot + this.x += (-ny) * sp * 0.6 * dt + nx * sp * 0.2 * dt; + this.y += (nx) * sp * 0.6 * dt + ny * sp * 0.2 * dt; + this.face = Math.atan2(ny, nx); + this.shootCd -= dt; + if (this.shootCd <= 0) { + this.shootCd = ranged.rof * R(0.8, 1.2); + hooks().enemyBullet(this.x, this.y, nx, ny, ranged.speed, ranged.dmg, this.def.color); + } + } else if (this.isBoss) { + this.updateBoss(g, dt, nx, ny, sp, d); + } else if (this.def.dash) { + // Pachycephalosaurus: telegraph, then head-butt charge + var D = this.def.dash; + if (this.dashing > 0) { + this.dashing -= dt; + this.x += Math.cos(this.face) * D.speed * dt; + this.y += Math.sin(this.face) * D.speed * dt; + } else { + this.dashCd = (this.dashCd === undefined) ? R(1.2, D.cd) : this.dashCd - dt; + this.wobble += dt * 3; + var wobP = Math.sin(this.wobble) * 0.25; + this.x += (nx - ny * wobP) * sp * 0.75 * dt; + this.y += (ny + nx * wobP) * sp * 0.75 * dt; + this.face = Math.atan2(ny + nx * wobP, nx - ny * wobP); + if (this.dashCd <= 0 && d < 420) { + this.dashing = D.dur; + this.dashCd = D.cd; + this.face = Math.atan2(ny, nx); // lock aim at wind-up end + } + } + } else { + // slight per-enemy weave so groups look organic + this.wobble += dt * 3; + var wob = Math.sin(this.wobble) * 0.25; + this.x += (nx - ny * wob) * sp * dt; + this.y += (ny + nx * wob) * sp * dt; + this.face = Math.atan2(ny + nx * wob, nx - ny * wob); + } + + if (this.charging > 0) { + this.charging -= dt; + this.x += this.chargeDir.x * this.speed * 4.2 * dt; + this.y += this.chargeDir.y * this.speed * 4.2 * dt; + this.face = Math.atan2(this.chargeDir.y, this.chargeDir.x); + } + + if (this.hitFlash > 0) this.hitFlash -= dt; + if (this.touchCd > 0) this.touchCd -= dt; + // heavy beasts kick up dust as they stomp around + if (this.radius >= 17 && Math.random() < dt * 2.5) { + US.entities.spawnParticles(null, this.x, this.y, '#8f7448', 1, 0.8, 3.5); + } + + // contact damage to player + if (d < this.radius + p.radius && this.touchCd <= 0 && !p.invulnHit()) { + p.hurt(this.contactDmg); + this.touchCd = 0.5; + if (p.stats.thorns > 0) g.damageEnemy(this, p.stats.thorns, false, true); + } + }; + + Enemy.prototype.updateBoss = function (g, dt, nx, ny, sp, dist) { + var pats = this.patterns; + this.patCd -= dt; + if (this.charging <= 0) { + this.x += nx * sp * dt; + this.y += ny * sp * dt; + } + if (this.patCd > 0) return; + var pick = pats[Math.floor(Math.random() * pats.length)]; + switch (pick) { + case 'charge': + this.chargeDir = { x: nx, y: ny }; + this.charging = 0.55; + this.patCd = 3.2; + break; + case 'radial': { + var n = 14; + for (var i = 0; i < n; i++) { + var a = (i / n) * Math.PI * 2 + Math.random() * 0.2; + g.enemyBullet(this.x, this.y, Math.cos(a), Math.sin(a), 200, 12, this.color); + } + this.patCd = 3.4; + break; + } + case 'aimed': { + for (var b = -1; b <= 1; b++) { + var ang = Math.atan2(ny, nx) + b * 0.18; + g.enemyBullet(this.x, this.y, Math.cos(ang), Math.sin(ang), 320, 12, this.color); + } + this.patCd = 1.6; + break; + } + case 'spiral': { + var self = this; + var count = 0; + var iv = setInterval(function () { + if (!self.alive || !US.game || US.game.over) { clearInterval(iv); return; } + var a = count * 0.55; + g.enemyBullet(self.x, self.y, Math.cos(a), Math.sin(a), 230, 11, self.color); + if (++count >= 16) clearInterval(iv); + }, 70); + this.patCd = 4.5; + break; + } + case 'spawn': { + for (var s = 0; s < 3; s++) { + var a2 = Math.random() * Math.PI * 2; + hooks().spawnEnemy('grunt', this.x + Math.cos(a2) * 70, this.y + Math.sin(a2) * 70, { hp: 0.7 }); + } + this.patCd = 6; + break; + } + case 'split': { + if (this.splitStage === 0 && this.hp < this.maxHp * 0.5) { + this.alive = false; // replaced below by children via game hook + hooks().splitBoss(this); + } else { + this.patCd = 2; + } + break; + } + } + }; + + /* ---------------- Bullets ---------------- */ + function Bullet(x, y, ang, speed, dmg, color, size, owner, opts) { + opts = opts || {}; + this.x = x; this.y = y; + this.vx = Math.cos(ang) * speed; + this.vy = Math.sin(ang) * speed; + this.dmg = dmg; + this.color = color; + this.size = size; + this.owner = owner; // 'player' | 'enemy' + this.life = opts.life || 1.6; + this.pierceLeft = opts.pierce || 0; + this.aoe = opts.aoe || 0; + this.ricochet = opts.ricochet || 0; + this.crit = !!opts.crit; + this.kind = opts.kind || null; // sprite key for js/art.js + this.hitIds = []; + this.alive = true; + } + + Bullet.prototype.update = function (g, dt) { + this.x += this.vx * dt; + this.y += this.vy * dt; + this.life -= dt; + if (this.life <= 0) { this.alive = false; return; } + + if (this.owner === 'player') { + for (var i = 0; i < g.enemies.length; i++) { + var e = g.enemies[i]; + if (!e.alive || e.spawnAnim > 0) continue; + if (this.hitIds.indexOf(e.eid) >= 0) continue; + var dx = e.x - this.x, dy = e.y - this.y; + if (dx * dx + dy * dy < (e.radius + this.size) * (e.radius + this.size)) { + this.hitIds.push(e.eid); + if (e.isFighter) e.lastHitBy = 0; // player kill credit + hooks().damageEnemy(e, this.dmg, this.crit); + if (this.aoe && e.alive !== undefined) hooks().explode(this.x, this.y, this.aoe, this.dmg * 0.6); + if (this.ricochet > 0) { + var nxt = hooks().nearestEnemy(this.x, this.y, 340, this.hitIds); + if (nxt) { + this.ricochet--; + var a = Math.atan2(nxt.y - this.y, nxt.x - this.x); + var spd = Math.hypot(this.vx, this.vy); + this.vx = Math.cos(a) * spd; this.vy = Math.sin(a) * spd; + this.life = Math.max(this.life, 0.8); + this.hitIds.length = 0; + this.hitIds.push(nxt.eid); + return; + } + } + if (this.pierceLeft > 0) { this.pierceLeft--; } + else { this.alive = false; } + break; + } + } + } else { + // enemy bullets hit the player… + var p = g.player; + var pdx = p.x - this.x, pdy = p.y - this.y; + if (pdx * pdx + pdy * pdy < (p.radius + this.size) * (p.radius + this.size)) { + if (!p.invulnHit()) p.hurt(this.dmg); + this.alive = false; + return; + } + // …and in the arena, bot bullets also hit other fighters + if (typeof this.ownerEid === 'number' && g.mode === 'arena') { + for (var fi = 0; fi < g.enemies.length; fi++) { + var f = g.enemies[fi]; + if (!f.alive || !f.isFighter || f.eid === this.ownerEid || f.spawnAnim > 0) continue; + var fdx = f.x - this.x, fdy = f.y - this.y; + if (fdx * fdx + fdy * fdy < (f.radius + this.size) * (f.radius + this.size)) { + f.lastHitBy = this.ownerEid; + hooks().damageEnemy(f, this.dmg, false, true); + this.alive = false; + break; + } + } + } + } + }; + + /* ---------------- Pickups ---------------- */ + function Pickup(x, y, kind, amount) { + this.x = x; this.y = y; + this.kind = kind; // coin | heal | bomb + this.amount = amount || 1; + this.life = kind === 'coin' ? 12 : 10; + this.t = Math.random() * Math.PI * 2; + this.vx = R(-40, 40); this.vy = R(-40, 40); + this.alive = true; + } + + Pickup.prototype.update = function (g, dt) { + this.life -= dt; + this.t += dt * 5; + if (this.life <= 0) { this.alive = false; return; } + var p = g.player; + var dx = p.x - this.x, dy = p.y - this.y; + var d = Math.hypot(dx, dy); + var range = p.stats.magnet; + if (d < range) { + var pull = 260 + (range - d) * 3; + this.x += (dx / d) * pull * dt; + this.y += (dy / d) * pull * dt; + } else { + this.x += this.vx * dt; this.y += this.vy * dt; + this.vx *= 0.92; this.vy *= 0.92; + } + if (d < p.radius + 10) { + this.alive = false; + hooks().collect(this); + } + }; + + /* ---------------- Particles ---------------- */ + var particles = []; + + function spawnParticles(g, x, y, color, n, power, size) { + for (var i = 0; i < n; i++) { + if (particles.length > 600) particles.shift(); + var a = Math.random() * Math.PI * 2; + var v = R(30, 60) * power * (0.4 + Math.random()); + particles.push({ + x: x, y: y, + vx: Math.cos(a) * v, vy: Math.sin(a) * v, + life: R(0.25, 0.7), maxLife: 0.7, + color: color, size: size || 3 + }); + } + } + function updateParticles(dt) { + for (var i = particles.length - 1; i >= 0; i--) { + var pt = particles[i]; + pt.x += pt.vx * dt; pt.y += pt.vy * dt; + pt.vx *= 0.94; pt.vy *= 0.94; + pt.life -= dt; + if (pt.life <= 0) particles.splice(i, 1); + } + } + function drawParticles(ctx) { + for (var i = 0; i < particles.length; i++) { + var pt = particles[i]; + ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife); + ctx.fillStyle = pt.color; + ctx.fillRect(pt.x - pt.size / 2, pt.y - pt.size / 2, pt.size, pt.size); + } + ctx.globalAlpha = 1; + } + function clearParticles() { particles.length = 0; } + + /* ---------------- Floating text ---------------- */ + var floaters = []; + function floatText(x, y, text, color, big) { + floaters.push({ x: x, y: y, text: text, color: color, life: 0.9, big: !!big }); + if (floaters.length > 40) floaters.shift(); + } + function updateFloaters(dt) { + for (var i = floaters.length - 1; i >= 0; i--) { + var f = floaters[i]; + f.y -= 34 * dt; + f.life -= dt; + if (f.life <= 0) floaters.splice(i, 1); + } + } + function drawFloaters(ctx) { + ctx.textAlign = 'center'; + for (var i = 0; i < floaters.length; i++) { + var f = floaters[i]; + ctx.globalAlpha = Math.min(1, f.life * 2); + ctx.font = (f.big ? 'bold 22px' : 'bold 13px') + ' "Segoe UI", system-ui, sans-serif'; + ctx.fillStyle = f.color; + ctx.fillText(f.text, f.x, f.y); + } + ctx.globalAlpha = 1; + } + function clearFloaters() { floaters.length = 0; } + + US.entities = { + Player: Player, + Enemy: Enemy, + Bullet: Bullet, + Pickup: Pickup, + spawnParticles: spawnParticles, + updateParticles: updateParticles, + drawParticles: drawParticles, + clearParticles: clearParticles, + floatText: floatText, + updateFloaters: updateFloaters, + drawFloaters: drawFloaters, + clearFloaters: clearFloaters + }; +})(); diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..3c26838 --- /dev/null +++ b/js/game.js @@ -0,0 +1,1964 @@ +/* Neon Rampage — game engine: campaign (waves, bosses, On Fire, cards) & arena vs bots. */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + var util = US.util; + var E; + + var W = 1280, H = 720; // logical world size + var WALL = 26; // arena inset + + var REVIVE_COST = 150; + + /* ================================================================ + Run construction + ================================================================ */ + function newRun(mode, opts) { + opts = opts || {}; + E = US.entities; + var save = US.save.get(); + var hero = US.HEROES[save.hero] || US.HEROES.recruit; + var skin = US.SKINS[save.skin] || US.SKINS.default; + var weaponId = opts.weaponId || save.weapon; + + var g = { + mode: mode, // 'campaign' | 'arena' + state: 'playing', // playing | cards | paused | revive | over + time: 0, + over: false, + W: W, H: H, + + player: new E.Player({ + x: W / 2, y: H / 2, + hero: hero, + skin: skin, + weaponId: weaponId, + bombs: mode === 'campaign' ? (save.bombs | 0) : 2 + }), + + enemies: [], + bullets: [], + pickups: [], + rings: [], // shockwaves/novas + + // campaign state + wave: 0, + spawnQueue: [], // [{defId, mods, t}] + waveActive: false, + + // scoring + score: 0, + kills: 0, + runCoins: 0, + combo: 0, + comboTimer: 0, + bestCombo: 0, + + // heat / on fire + heat: 0, + onFire: false, + onFireT: 0, + hangover: 0, + sinceKill: 0, + + reviveUsed: false, + reviving: false, + + // fx + shake: 0, + flashRed: 0, + announces: [], + decor: null, + roarT: 9, + decals: [], + prints: [], + stompCount: 0, + weather: 'clear', + weatherT: util.rand(20, 40), + flashWhite: 0, + thunderT: -1, + bloodMoon: false, + + // arena + arenaCfg: opts.arena || null, + arenaTime: 0, + + eid: 1 + }; + + g.decor = US.art.makeDecor(W, H, WALL); + + g.player.hurt = function (dmg) { hurtPlayer(g, dmg); }; + g.player.invulnHit = function () { return this.invuln > 0; }; + + if (mode === 'arena') setupArena(g); + else scheduleNextWave(g); + + return g; + } + + /* ================================================================ + Campaign waves + ================================================================ */ + function waveBudget(wave) { return Math.floor(55 + wave * 24 + Math.pow(wave, 1.35) * 4); } + + function availableEnemies(wave) { + return Object.keys(US.ENEMIES).filter(function (k) { + var d = US.ENEMIES[k]; + return !d.noSpawn && d.fromWave <= wave; + }); + } + + function buildWaveQueue(g, wave) { + var budget = waveBudget(wave); + if (wave > 25) budget = Math.floor(budget * (1 + (wave - 25) * 0.09)); + if (g.bloodMoon) budget = Math.floor(budget * 1.25); + var pool = availableEnemies(wave); + var q = []; + var t = 0; + var guard = 0; + while (budget > 0 && guard++ < 400) { + var id = util.pick(pool); + var def = US.ENEMIES[id]; + if (def.cost > budget && q.length) break; + var mods = { hp: 1 + wave * 0.06, dmg: 1 + wave * 0.02 }; + if (def.elite) mods.elite = true; + budget -= def.cost; + t += util.rand(0.35, 1.05); + q.push({ defId: id, mods: mods, t: t }); + } + return q; + } + + function biomeFor(wave) { + return wave < 10 ? 'jungle' : (wave < 19 ? 'swamp' : 'ash'); + } + + function enterBiome(g, bio) { + g.biome = bio; + var names = { + jungle: { en: '🌿 The Green Jungle', vi: '🌿 Rừng Xanh Lục' }, + swamp: { en: '💀 The Murk Swamp', vi: '💀 Đầm Lầy U Ám' }, + ash: { en: '🌋 The Ashlands', vi: '🌋 Tro Núi Lửa' } + }; + announce(g, L(names[bio], metaLang()), '#ffd24a'); + + if (bio === 'swamp' && !g.swampPitsAdded && g.decor) { + g.swampPitsAdded = true; // the muck spreads: two extra tar pits + for (var i2 = 0; i2 < 2; i2++) { + var ang = util.rand(0, Math.PI * 2); + g.decor.tarPits.push({ + x: W / 2 + Math.cos(ang) * util.rand(180, 380), + y: H / 2 + Math.sin(ang) * util.rand(140, 260), + r: util.rand(52, 70), seed: Math.random() * 9 + }); + } + } + if (bio === 'ash' && !g.ashCracksMade && g.decor) { + g.ashCracksMade = true; // lava veins sear anyone standing on them + g.decor.cracks = []; + for (var c3 = 0; c3 < 3; c3++) { + var ca = c3 * Math.PI * 2 / 3 + util.rand(-0.4, 0.4); + var cr2 = { + x: W / 2 + Math.cos(ca) * util.rand(200, 400), + y: H / 2 + Math.sin(ca) * util.rand(150, 260), + pts: [] + }; + var px = 0, py = 0; + for (var si = 0; si < 5; si++) { + px += util.rand(18, 34); py += util.rand(-14, 14); + cr2.pts.push({ x: px, y: py }); + } + cr2.r = 40; + g.decor.cracks.push(cr2); + } + } + } + + function inCrack(g, x, y) { + if (!g.decor || !g.decor.cracks) return false; + for (var i = 0; i < g.decor.cracks.length; i++) { + var c = g.decor.cracks[i]; + if (util.dist2(x, y, c.x + 40, c.y) < c.r * c.r) return true; + } + return false; + } + + function scheduleNextWave(g) { + g.wave++; + g.waveActive = true; + g.spawnQueue = buildWaveQueue(g, g.wave); + + // act biomes: jungle -> swamp -> ashlands + var wantBio = biomeFor(g.wave); + if (g.biome !== wantBio) enterBiome(g, wantBio); + + if (g.wave % 5 === 0) { + announce(g, US.i18n.t('announce.boss'), '#e04b3a'); + US.audio.sfx('bossRoar'); + spawnBoss(g); + roarFx(g); + } else { + announce(g, US.i18n.t('announce.wave') + ' ' + g.wave, '#f3e7cf'); + } + if (g.wave > 25 && !g.endlessAnnounced) { + g.endlessAnnounced = true; + announce(g, L({ en: 'ENDLESS MODE', vi: 'CHẾ ĐỘ VÔ TẬN' }, metaLang()), '#ff8a3d'); + } + questProgress(g, 'wave', g.wave, true); + checkAch(g); + } + + function spawnEdgePoint(g) { + var m = 40; + var side = util.randInt(0, 3); + if (side === 0) return { x: util.rand(WALL + m, W - WALL - m), y: WALL - m }; + if (side === 1) return { x: W - WALL + m, y: util.rand(WALL + m, H - WALL - m) }; + if (side === 2) return { x: util.rand(WALL + m, W - WALL - m), y: H - WALL + m }; + return { x: WALL - m, y: util.rand(WALL + m, H - WALL - m) }; + } + + function spawnBoss(g) { + var idx = Math.floor((g.wave / 5 - 1) % US.BOSSES.length); + var tierHp = 1 + Math.floor((g.wave - 5) / 15) * 0.9; + var def = US.BOSSES[idx]; + var b = new E.Enemy(def, W / 2, -80, { + hp: (1 + g.wave * 0.09) * tierHp, + isBoss: true + }); + b.bossDef = def; + b.eid = g.eid++; + g.enemies.push(b); + } + + // sonic roar: expanding visual ring + heavy shake as the alpha arrives + function roarFx(g) { + var b = null; + for (var i = 0; i < g.enemies.length; i++) if (g.enemies[i].isBoss) b = g.enemies[i]; + if (!b) return; + g.rings.push({ x: b.x, y: b.y, r: 8, maxR: 300, life: 0.6, maxLife: 0.6, color: '#e04b3a', dmg: 0, hit: [] }); + g.shake = Math.max(g.shake, 14); + US.entities.spawnParticles(null, b.x, b.y, '#c9bda6', 20, 3, 3); + } + + function splitBoss(g, boss) { + var n = 2; + // parent's body counts for partial credit before it splits + var def = boss.bossDef || {}; + g.score += Math.round((def.score || 400) * 0.4); + g.runCoins += Math.round(util.randInt(def.coin ? def.coin[0] : 20, def.coin ? def.coin[1] : 30) * 0.5); + announce(g, 'IT SPLITS!', '#58a55c'); + for (var i = 0; i < n; i++) { + var child = new E.Enemy(boss.def, boss.x + util.rand(-50, 50), boss.y + util.rand(-50, 50), { + hp: boss.maxHp * 0.32, + size: 0.62, + isBoss: true, + splitStage: 1 + }); + child.bossDef = boss.def; + child.patterns = boss.patterns.filter(function (p) { return p !== 'split'; }); + child.patCd = 1.5; + child.eid = g.eid++; + g.enemies.push(child); + } + boomFx(g, boss.x, boss.y, boss.color, 40, 260); + } + + /* ================================================================ + Spawning helpers + ================================================================ */ + function spawnEnemyAt(g, defId, x, y, mods) { + var def = typeof defId === 'string' ? US.ENEMIES[defId] : defId; + if (!def) return null; + var e = new E.Enemy(def, x, y, mods || {}); + e.eid = g.eid++; + g.enemies.push(e); + return e; + } + + /* ================================================================ + Arena (local PvP vs bots) + ================================================================ */ + function makeBot(g, name, tint) { + var wid = util.pick(['pistol', 'smg', 'shotgun']); + var f = { + isFighter: true, + isBot: true, + eid: g.eid++, + name: name, + color: tint, + x: util.rand(120, W - 120), + y: util.rand(120, H - 120), + vx: 0, vy: 0, + radius: 15, + maxHp: 85, + hp: 85, + alive: true, + spawnAnim: 0.5, + weapon: US.WEAPONS[wid], + shootCd: util.rand(0.4, 1.2), + burst: 0, + strafe: Math.random() < 0.5 ? 1 : -1, + strafeT: util.rand(0.6, 1.6), + targetEid: null, + kills: 0, + deaths: 0, + respawnT: 0, + hitFlash: 0 + }; + return f; + } + + function setupArena(g) { + var cfg = g.arenaCfg; + g.targetKills = cfg.target; + g.arenaTimeLeft = 0; // untimed unless configured + var names = ['VEX', 'JOLT', 'RUIN', 'HALE']; + var tints = ['#ff8a3d', '#b06bff', '#57d98a', '#ff6ea9']; + g.bots = []; + for (var i = 0; i < cfg.bots; i++) g.bots.push(makeBot(g, names[i], tints[i])); + // fighters live in g.enemies so bullets hit them + g.enemies = g.bots.slice(); + announce(g, US.i18n.t('arena.leader', { n: g.targetKills }), '#ffd24a'); + } + + function respawnFighter(g, f) { + f.alive = true; + f.hp = f.maxHp; + f.spawnAnim = 0.5; + // spawn far from others + var best = null, bestD = -1; + for (var tries = 0; tries < 12; tries++) { + var px = util.rand(90, W - 90), py = util.rand(90, H - 90); + var d = 1e9; + for (var j = 0; j < g.enemies.length; j++) { + var o = g.enemies[j]; + if (!o.alive || o === f) continue; + d = Math.min(d, util.dist2(px, py, o.x, o.y)); + } + d = Math.min(d, util.dist2(px, py, g.player.x, g.player.y)); + if (d > bestD) { bestD = d; best = { x: px, y: py }; } + } + f.x = best.x; f.y = best.y; + boomFx(g, f.x, f.y, f.color, 12, 140); + } + + function updateBots(g, dt) { + var p = g.player; + for (var i = 0; i < g.bots.length; i++) { + var b = g.bots[i]; + if (!b.alive) { + b.respawnT -= dt; + if (b.respawnT <= 0) respawnFighter(g, b); + continue; + } + if (b.spawnAnim > 0) { b.spawnAnim -= dt; continue; } + if (b.hitFlash > 0) b.hitFlash -= dt; + + // choose nearest living target (player counts as fighter id 0) + var tx, ty, td = 1e9; + var cands = []; + if (p.alive) cands.push(p); + for (var k = 0; k < g.bots.length; k++) { + var o = g.bots[k]; + if (o !== b && o.alive) cands.push(o); + } + for (var c = 0; c < cands.length; c++) { + var d2 = util.dist2(b.x, b.y, cands[c].x, cands[c].y); + if (d2 < td) { td = d2; tx = cands[c].x; ty = cands[c].y; } + } + if (tx === undefined) continue; + var dist = Math.sqrt(td) || 1; + var nx = (tx - b.x) / dist, ny = (ty - b.y) / dist; + + // approach until preferred range, then strafe + b.strafeT -= dt; + if (b.strafeT <= 0) { b.strafe *= -1; b.strafeT = util.rand(0.6, 1.8); } + var want = b.weapon.id === 'shotgun' ? 170 : b.weapon.id === 'pistol' ? 260 : 220; + var move = dist > want ? 1 : dist < want * 0.6 ? -0.7 : 0; + var sp = 165; + b.x += (nx * move + -ny * b.strafe * 0.8) * sp * dt; + b.y += (ny * move + nx * b.strafe * 0.8) * sp * dt; + clampToWorld(b); + b.face = Math.atan2(ny, nx); + b.moving = true; + b.walkPhase = (b.walkPhase || 0) + dt * 12; + if (Math.random() < 0.08) { + US.entities.spawnParticles(null, b.x - nx * 12, b.y - ny * 12, '#9a7d52', 1, 0.6, 2.5); + } + + // fire + b.shootCd -= dt; + if (dist < 520 && b.shootCd <= 0) { + var ang = Math.atan2(ty - b.y, tx - b.x) + util.rand(-0.07, 0.07); + fireWeaponBullets(g, b, ang, 0.75); + b.shootCd = 1 / (b.weapon.rof * 0.55); + } + } + } + + function fighterDied(g, f, killerLabel) { + f.alive = false; + f.deaths++; + f.respawnT = 2.2; + boomFx(g, f.x, f.y, f.color, 30, 240); + US.audio.sfx('explode'); + var byPlayer = killerLabel === 'player' || killerLabel === 0; + if (byPlayer) { + g.kills++; + g.score += 100; + g.runCoins += 6; + g.player.ult = Math.min(100, g.player.ult + 6 * g.player.stats.ultRate); + addHeat(g, 14); + US.entities.floatText(f.x, f.y - 20, '+100', '#ffb938'); + checkArenaWin(g); + } else if (f.isBot && typeof killerLabel === 'number') { + // bot killed bot — credit if we tracked shooter eid + var killer = findFighterByEid(g, f.lastHitBy); + if (killer && killer.isBot) { + killer.kills++; + checkArenaWin(g); + } + } + } + + function findFighterByEid(g, eid) { + if (eid === 0) return g.player; + for (var i = 0; i < g.bots.length; i++) if (g.bots[i].eid === eid) return g.bots[i]; + return null; + } + + function checkArenaWin(g) { + if (g.state === 'over') return; + var winner = null; + if (g.kills >= g.targetKills) winner = { name: US.i18n.t('arena.you'), player: true }; + else { + for (var i = 0; i < g.bots.length; i++) { + if (g.bots[i].kills >= g.targetKills) winner = { name: g.bots[i].name, player: false }; + } + } + if (winner) finishArena(g, winner); + } + + function finishArena(g, winner) { + g.state = 'over'; + g.over = true; + US.save.addCoins(g.runCoins); + if (winner.player) US.audio.sfx('win'); else US.audio.sfx('lose'); + if (US.ui) US.ui.arenaOver(winner, g); + } + + /* ================================================================ + Combat plumbing + ================================================================ */ + function fireWeaponBullets(g, shooter, ang, dmgScale) { + var isPlayer = shooter === g.player; + var w = shooter.weapon; + var s = isPlayer ? shooter.stats : { pellets: 0, pierce: 0, crit: 0.03, critMul: 2, bulletSpeedMul: 1, bulletSizeMul: 1, explosive: 0, ricochet: 0 }; + var n = w.pellets + (s.pellets || 0); + var baseDmg = (w.dmg * (isPlayer ? s.dmgMul : 0.5)) * (isPlayer && g.onFire ? 2 : 1) * (dmgScale || 1); + for (var i = 0; i < n; i++) { + var off = (i - (n - 1) / 2) * (w.spread || 0.08); + var a = ang + off + (w.spread ? util.rand(-w.spread, w.spread) * 0.5 : 0); + var crit = Math.random() < (s.crit || 0); + var b = new E.Bullet( + shooter.x + Math.cos(ang) * (shooter.radius + 6), + shooter.y + Math.sin(ang) * (shooter.radius + 6), + a, + w.speed * (s.bulletSpeedMul || 1), + baseDmg * (crit ? (s.critMul || 2) : 1), + isPlayer && g.onFire ? '#ffd24a' : w.color, + w.size * (s.bulletSizeMul || 1), + isPlayer ? 'player' : 'e' + shooter.eid, + { + pierce: (w.pierce || 0) + (s.pierce || 0), + aoe: w.aoe || 0, + ricochet: isPlayer ? (s.ricochet || 0) : 0, + crit: crit, + kind: w.kind + } + ); + b.ownerEid = isPlayer ? 0 : shooter.eid; + b.life = 1.9; + g.bullets.push(b); + } + shooter.fireT = 0.09; + if (isPlayer) { + US.audio.sfx(w.sfx); + g.shake = Math.min(g.shake + (w.id === 'rail' ? 3 : 1), 8); + } + } + + function enemyBullet(g, x, y, nx, ny, speed, dmg, color) { + var b = new E.Bullet(x, y, Math.atan2(ny, nx), speed, dmg, color || '#ff5e7a', 6, 'enemy', {}); + b.enemyShot = true; + g.bullets.push(b); + } + + function damageEnemy(g, e, dmg, crit, silent) { + if (!e.alive) return; + e.hp -= dmg; + e.hitFlash = 0.1; + if (!silent) { + US.entities.floatText(e.x + util.rand(-8, 8), e.y - e.radius - 6, + String(Math.round(dmg)), crit ? '#ffd24a' : '#ffffff'); + if (!crit) US.audio.sfx('hit'); + } + // lifesteal + if (g.player.alive && g.player.stats.lifesteal > 0 && e.hp > 0) { + g.player.hp = Math.min(g.player.stats.maxHp, g.player.hp + dmg * g.player.stats.lifesteal * 0.4); + } + if (e.hp <= 0) killEnemy(g, e); + } + + /* persistent blood stains that slowly fade */ + function bloodDecal(g, e) { + if (e.isFighter) return; + var n = e.isBoss ? 5 : 2; + for (var i = 0; i < n; i++) { + if (g.decals.length > 70) g.decals.shift(); + g.decals.push({ + x: e.x + util.rand(-e.radius, e.radius), + y: e.y + util.rand(-e.radius, e.radius), + rx: e.radius * util.rand(0.7, 1.5), + ry: e.radius * util.rand(0.45, 1.0), + rot: util.rand(0, Math.PI), + life: 16, + dark: Math.random() < 0.4 + }); + } + } + + function drawPrints(ctx, g) { + for (var i = 0; i < g.prints.length; i++) { + var pt = g.prints[i]; + ctx.globalAlpha = 0.26 * Math.min(1, pt.life / 3); + ctx.fillStyle = '#4a3826'; + ctx.beginPath(); + ctx.ellipse(pt.x, pt.y, 4.2, 2.6, pt.rot, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + function drawDecals(ctx, g) { + for (var i = 0; i < g.decals.length; i++) { + var dc = g.decals[i]; + ctx.globalAlpha = 0.4 * Math.min(1, dc.life / 12); + ctx.fillStyle = dc.dark ? '#571810' : '#7e2418'; + ctx.beginPath(); + ctx.ellipse(dc.x, dc.y, dc.rx, dc.ry, dc.rot, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + function updateDecals(g, dt) { + for (var i = g.decals.length - 1; i >= 0; i--) { + g.decals[i].life -= dt; + if (g.decals[i].life <= 0) g.decals.splice(i, 1); + } + } + + /* ------------- meta progression: bestiary / quests / achievements ------- */ + function L(o, lang) { return (o && (o[lang] || o.en)) || ''; } + function metaLang() { return US.save.get().lang === 'vi' ? 'vi' : 'en'; } + + function speciesOf(e) { + if (e.isBoss) return (e.bossDef && (e.bossDef.id || e.bossDef.art)) || null; + var d = e.def; + if (!d) return null; + if (d._spKey) return d._spKey; + for (var k in US.ENEMIES) { + if (US.ENEMIES[k] === d) { d._spKey = k; return k; } + } + return null; + } + + function ensureQuests(sv) { + var today = US.save.today(); + if (sv.quests.date === today && sv.quests.items.length === 3) return; + var y = new Date(Date.now() - 864e5); + var yest = y.getFullYear() + '-' + ('0' + (y.getMonth() + 1)).slice(-2) + '-' + ('0' + y.getDate()).slice(-2); + if (sv.quests.lastDay !== today) { + sv.quests.streak = (sv.quests.lastDay === yest ? (sv.quests.streak | 0) : 0) + 1; + sv.quests.lastDay = today; + } + var seed = 7; + today.split('').forEach(function (ch) { seed = (seed * 31 + ch.charCodeAt(0)) % 1e9; }); + var pool = US.QUEST_POOL.slice(); + var picks = []; + while (picks.length < 3 && pool.length) { + seed = (seed * 1103515245 + 12345) % 2147483647; + picks.push(pool.splice(seed % pool.length, 1)[0]); + } + sv.quests.date = today; + sv.quests.items = picks.map(function (d) { + return { defId: d.id, type: d.type, goal: d.goal, prog: 0, done: false }; + }); + US.save.save(); + if (US.ui) US.ui.questBadge(); + } + + function questProgress(g, type, n, isMax) { + var sv = US.save.get(); + ensureQuests(sv); + var changed = false; + sv.quests.items.forEach(function (q) { + if (q.type !== type || q.done) return; + q.prog = isMax ? Math.max(q.prog | 0, n | 0) : (q.prog | 0) + n; + if (q.prog >= q.goal) { q.prog = q.goal; q.done = true; changed = true; } + }); + if (changed) { + US.save.save(); + if (US.ui) US.ui.questBadge(); + announce(g, '📜 ' + L({ en: 'Quest complete!', vi: 'Nhiệm vụ xong!' }, metaLang()), '#ffd24a'); + } + } + + function checkAch(g) { + var sv = US.save.get(); + var lang = metaLang(); + Object.keys(US.ACHIEVEMENTS).forEach(function (id) { + if (US.save.hasAch(id)) return; + var def = US.ACHIEVEMENTS[id]; + var hit = false; + try { hit = def.test(sv, g); } catch (e) { /* g-dependent ones need a run */ } + if (hit && US.save.unlockAch(id)) { + US.save.addGems(def.gem); + if (g) announce(g, '🏆 ' + L(def.text, lang) + ' +' + def.gem + L({ en: ' flames', vi: ' lửa' }, lang), '#ffd24a'); + US.audio.sfx('buy'); + } + }); + } + + /* ---------------- the tribe's pet raptor -------------------------------- */ + function makePet(g) { + var pd = US.save.get().pet || { name: 'Rex Jr', color: '#5f8f3e' }; + return { + x: g.player.x - 34, y: g.player.y + 22, + face: 0, walkPhase: 0, seed: Math.random(), + shootT: 1.4, name: pd.name, color: pd.color, radius: 9 + }; + } + + function updatePet(g, dt) { + var pt = g.pet, p = g.player; + if (!pt || !p.alive || g.state !== 'playing') return; + var tx = p.x - Math.cos(p.aim) * 36; + var ty = p.y - Math.sin(p.aim) * 36 + 20; + var dx = tx - pt.x, dy = ty - pt.y; + var d = Math.hypot(dx, dy) || 1; + var moving = false; + if (d > 24) { + var sp2 = Math.min(d * 4.2, 320); + pt.x += dx / d * sp2 * dt; + pt.y += dy / d * sp2 * dt; + moving = true; + pt.face = Math.atan2(dy, dx); + } + pt.walkPhase = (pt.walkPhase || 0) + dt * (moving ? 13 : 3); + + pt.shootT -= dt; + if (pt.shootT <= 0) { + var tgt = null, bd = 280 * 280; + for (var i = 0; i < g.enemies.length; i++) { + var e = g.enemies[i]; + if (!e.alive || e.isFighter || e.spawnAnim > 0) continue; + var dd = (e.x - pt.x) * (e.x - pt.x) + (e.y - pt.y) * (e.y - pt.y); + if (dd < bd) { bd = dd; tgt = e; } + } + if (tgt) { + pt.face = Math.atan2(tgt.y - pt.y, tgt.x - pt.x); + var ang = new E.Bullet(pt.x + Math.cos(pt.face) * 10, pt.y + Math.sin(pt.face) * 10, + pt.face, 340, 5, '#d8ccb0', 3.4, 'player', { kind: 'stone', life: 1 }); + ang.eid = 0; + g.bullets.push(ang); + pt.shootT = 1.25; + if (Math.random() < 0.35) US.audio.sfx('shoot'); + } else { + pt.shootT = 0.4; + } + } + } + + function killEnemy(g, e) { + e.alive = false; + var def = e.isFighter ? null : e.def; + + if (e.isFighter) { + fighterDied(g, e, e.lastHitBy); + return; + } + + boomFx(g, e.x, e.y, e.color, e.isBoss ? 60 : 14, e.isBoss ? 320 : e.radius * 8); + bloodDecal(g, e); + US.audio.sfx(e.isBoss ? 'explode' : 'kill'); + + g.kills++; + g.combo++; + g.comboTimer = 3; + g.bestCombo = Math.max(g.bestCombo, g.combo); + g.sinceKill = 0; + + // ---- meta progression on every slain beast ---- + var spKey = speciesOf(e); + if (spKey && US.BESTIARY[spKey]) { + var bDef = US.BESTIARY[spKey]; + US.save.bumpBestiary(spKey); + var bEntry = US.save.get().bestiary[spKey]; + if (bEntry.kills >= bDef.goal && US.save.bestiaryDone(spKey, function () { + US.save.addGems(bDef.gem); + })) { + announce(g, '📖 ' + bDef.name[metaLang()] + ' ✓ +' + bDef.gem + + L({ en: ' flames', vi: ' lửa' }, metaLang()), '#ffd24a'); + US.audio.sfx('buy'); + } + } + questProgress(g, 'kill', 1); + if (e.isBoss) questProgress(g, 'boss', 1); + US.save.stat('totalKills', 1); + checkAch(g); + + var mult = (1 + Math.min(g.combo, 50) * 0.02) * (g.onFire ? 2 : 1); + g.score += Math.round(def.score * mult); + + // ultimate + heat + g.player.ult = Math.min(100, g.player.ult + 4 * g.player.stats.ultRate); + addHeat(g, e.isBoss ? 40 : def.value * 7); + + // coins + var cn = util.randInt(def.coin[0], def.coin[1]); + var per = Math.max(1, Math.round(cn / Math.min(cn, 3))); + var left = cn; + while (left > 0) { + var amt = Math.min(per, left); + left -= amt; + g.pickups.push(new E.Pickup(e.x + util.rand(-14, 14), e.y + util.rand(-14, 14), 'coin', amt)); + } + if (Math.random() < 0.04) g.pickups.push(new E.Pickup(e.x, e.y, 'heal', 20)); + if (Math.random() < 0.016) g.pickups.push(new E.Pickup(e.x, e.y, 'bomb', 1)); + + // splitters + if (def.splits) { + for (var i = 0; i < def.splits.count; i++) { + var a = Math.PI * 2 * i / def.splits.count + Math.random(); + spawnEnemyAt(g, def.splits.into, e.x + Math.cos(a) * 24, e.y + Math.sin(a) * 24, { hp: 1 }); + } + } + + if (e.isBoss) { + announce(g, 'BOSS DOWN', '#ffb938'); + g.shake = 16; + US.save.addGems(2); + if (!g.petDropped && !US.save.get().pet && Math.random() < 0.35) { + g.petDropped = true; + g.pickups.push(new E.Pickup(e.x, e.y, 'egg', 1)); + } + g.pickups.push(new E.Pickup(e.x, e.y, 'heal', 40)); + g.pickups.push(new E.Pickup(e.x + 30, e.y, 'bomb', 1)); + } + } + + function explodeAt(g, x, y, radius, dmg) { + boomFx(g, x, y, '#ffb35e', 18, radius * 1.4); + g.rings.push({ x: x, y: y, r: 6, maxR: radius, life: 0.28, maxLife: 0.28, color: '#ffb35e', dmg: dmg, hit: [] }); + } + + /* ================================================================ + Heat / On Fire + ================================================================ */ + function addHeat(g, v) { + if (g.onFire) return; + if (g.hangover > 0) v *= 0.5; + g.heat = Math.min(100, g.heat + v); + if (g.heat >= 100) igniteOnFire(g); + } + + function igniteOnFire(g) { + g.onFire = true; + g.onFireT = 7; + g.heat = 100; + announce(g, US.i18n.t('announce.onFire'), '#ffb938', true); + US.audio.sfx('onfire'); + } + + function endOnFire(g) { + g.onFire = false; + g.heat = 0; + g.hangover = 5; + announce(g, US.i18n.t('announce.hangover'), '#9c8a72'); + } + + /* ================================================================ + Player damage / death / revive + ================================================================ */ + function spdFactor(p) { return Math.min(8, p.speedBase * p.stats.speedMul / 40); } + + function inTar(g, x, y) { + var tp = g.decor && g.decor.tarPits; + if (!tp) return false; + for (var i = 0; i < tp.length; i++) { + if (util.dist2(x, y, tp[i].x, tp[i].y) < tp[i].r * tp[i].r * 0.72) return true; + } + return false; + } + + function hurtPlayer(g, dmg) { + var p = g.player; + if (!p.alive || p.invuln > 0) return; + if (US.save.get().settings.onehit && g.mode === 'campaign') dmg = p.stats.maxHp; + p.hp -= dmg; + p.hurtT = 0.4; + g.flashRed = 0.25; + g.shake = Math.min(g.shake + 6, 14); + g.combo = 0; + US.audio.sfx('hurt'); + US.entities.floatText(p.x, p.y - 24, '-' + Math.round(dmg), '#ff5e7a'); + if (p.hp <= 0) { + p.hp = 0; + p.alive = false; + boomFx(g, p.x, p.y, p.skin.body, 40, 280); + if (g.mode === 'arena') { + g.arenaDeaths = (g.arenaDeaths || 0) + 1; + g.playerRespawnT = 2.2; + } else { + offerReviveOrEnd(g); + } + } + } + + function offerReviveOrEnd(g) { + var save = US.save.get(); + if (!g.reviveUsed && save.coins >= REVIVE_COST) { + g.state = 'revive'; + g.reviveCountdown = 6; + if (US.ui) US.ui.showRevive(REVIVE_COST, g.reviveCountdown); + } else { + endRun(g); + } + } + + function acceptRevive(g) { + g.reviveUsed = true; + US.save.addCoins(-REVIVE_COST); + var p = g.player; + p.alive = true; + p.hp = p.stats.maxHp; + p.x = W / 2; p.y = H / 2; + p.invuln = 2.5; + g.state = 'playing'; + boomFx(g, p.x, p.y, '#7df9ff', 30, 240); + } + + function endRun(g) { + g.state = 'over'; + g.over = true; + var save = US.save.get(); + save.bombs = g.mode === 'campaign' ? g.player.bombs : save.bombs; + var record = US.save.submitRun(g.score, g.wave || 0); + US.save.addCoins(g.runCoins); + US.save.stat('runs', 1); + checkAch(g); + US.audio.sfx('lose'); + if (US.ui) US.ui.gameOver({ + score: g.score, + wave: g.wave, + kills: g.kills, + bestCombo: g.bestCombo, + coins: g.runCoins, + record: record + }, g.mode); + } + + /* ================================================================ + Player actions (called from input layer) + ================================================================ */ + function castUlt(g) { + var p = g.player; + if (!p.alive || g.state !== 'playing' || p.ult < 100) return; + p.ult = 0; + p.invuln = Math.max(p.invuln, 1); + g.rings.push({ x: p.x, y: p.y, r: 10, maxR: 430, life: 0.65, maxLife: 0.65, color: p.skin.body, dmg: 130, nova: true, hit: [] }); + g.shake = 12; + US.audio.sfx('ult'); + questProgress(g, 'ult', 1); + } + + function useBomb(g) { + var p = g.player; + if (!p.alive || g.state !== 'playing' || p.bombs <= 0) return; + p.bombs--; + g.rings.push({ x: p.x, y: p.y, r: 10, maxR: 520, life: 0.5, maxLife: 0.5, color: '#ff8a3d', dmg: 220, bomb: true, hit: [] }); + // wipe enemy bullets + for (var i = 0; i < g.bullets.length; i++) { + if (g.bullets[i].owner !== 'player') g.bullets[i].alive = false; + } + g.shake = 16; + US.audio.sfx('bomb'); + } + + /* ================================================================ + FX helpers + ================================================================ */ + function boomFx(g, x, y, color, n, power) { + US.entities.spawnParticles(null, x, y, color, n, power ? power / 60 : 4, 4); + g.rings.push({ x: x, y: y, r: 4, maxR: power ? power : 60, life: 0.22, maxLife: 0.22, color: color, dmg: 0, hit: [] }); + } + + function announce(g, text, color, big) { + g.announces.push({ text: text, color: color || '#fff', t: 0, dur: big ? 2.2 : 1.6, big: !!big }); + } + + /* ================================================================ + Main update + ================================================================ */ + function update(g, dt) { + if (!g.metaInit) { + g.metaInit = true; + g.player.bombs += g.player.stats.startBombs | 0; + if (US.save.get().pet) g.pet = makePet(g); + ensureQuests(US.save.get()); + if (US.save.isWeekend && US.save.isWeekend()) { + g.bloodMoon = true; + announce(g, L({ + en: '🌑 BLOOD MOON WEEKEND — amber ×2, herds swell!', + vi: '🌑 HUYẾT NGŨ CUỐI TUẦN — hổ phách ×2, thú về đông!' + }, metaLang()), '#ff6b6b'); + } + } + updatePet(g, dt); + if (g.state === 'over') return; + + // announcements always tick + for (var a = g.announces.length - 1; a >= 0; a--) { + g.announces[a].t += dt; + if (g.announces[a].t > g.announces[a].dur) g.announces.splice(a, 1); + } + + if (g.state !== 'playing') return; // cards/paused/revive freeze the world + + g.time += dt; + var p = g.player; + + // ---- player ---- + if (p.alive) { + var inp = US.input; + var mx = (inp.right ? 1 : 0) - (inp.left ? 1 : 0); + var my = (inp.down ? 1 : 0) - (inp.up ? 1 : 0); + if (inp.moveVec) { mx += inp.moveVec.x; my += inp.moveVec.y; } + var ml = Math.hypot(mx, my); + if (ml > 1) { mx /= ml; my /= ml; } + p.moving = ml > 0.15; + if (p.moving) { + p.walkPhase = (p.walkPhase || 0) + dt * (11 + spdFactor(p)); + } + var spd = p.speedBase * p.stats.speedMul; + if (inTar(g, p.x, p.y)) spd *= 0.55; + p.x += mx * spd * dt; + p.y += my * spd * dt; + clampToWorld(p); + + if (p.invuln > 0) p.invuln -= dt; + if (p.hurtT > 0) p.hurtT -= dt; + if (p.fireT > 0) p.fireT = Math.max(0, p.fireT - dt); + // molten ground burns in the ashlands + if (g.biome === 'ash' && p.alive && inCrack(g, p.x, p.y)) { + p.hp -= 7 * dt; + if (Math.random() < dt * 6) { + US.entities.spawnParticles(null, p.x, p.y, '#ff8a3d', 1, 0.7, 3); + US.entities.floatText(p.x, p.y - 24, L({ en: 'hot!', vi: 'nóng!' }, metaLang()), '#ff9b6b'); + } + if (p.hp <= 0) hurtPlayer(g, 1); + } + + // ON FIRE ember trail behind the hero + if (g.onFire && Math.random() < 0.45) { + US.entities.spawnParticles(null, + p.x - Math.cos(p.aim) * 14, p.y - Math.sin(p.aim) * 14, + Math.random() < 0.5 ? '#ff8a3d' : '#ffb938', 1, 0.9, 3); + } + // running dust + alternating footprints + if (p.moving) { + p.dustT = (p.dustT || 0) - dt; + if (p.dustT <= 0) { + p.dustT = 0.13; + US.entities.spawnParticles(null, + p.x - Math.cos(p.aim) * 10, p.y - Math.sin(p.aim) * 10, + '#9a7d52', 1, 0.7, 3); + var pa = p.aim || 0; + g.prints.push({ + x: p.x - Math.cos(pa) * 6 + Math.cos(pa + Math.PI / 2) * (p.printSide ? -5 : 5), + y: p.y - Math.sin(pa) * 6 + Math.sin(pa + Math.PI / 2) * (p.printSide ? -5 : 5), + rot: pa, life: 4 + }); + p.printSide = !p.printSide; + if (g.prints.length > 40) g.prints.shift(); + } + } + if (p.stats.regen > 0) p.hp = Math.min(p.stats.maxHp, p.hp + p.stats.regen * dt); + + // touch: right stick steers the aim point around the player + if (inp.aimVec && Math.hypot(inp.aimVec.x, inp.aimVec.y) > 0.15) { + inp.aimX = p.x + inp.aimVec.x * 300; + inp.aimY = p.y + inp.aimVec.y * 300; + } + p.aim = Math.atan2(inp.aimY - p.y, inp.aimX - p.x); + + // AUTO-AIM: hunter instinct locks the nearest prey in range + g.autoTarget = null; + if (US.save.get().settings.autoAim && p.alive) { + var tgt2 = nearestEnemy(g, p.x, p.y, 700, null); + if (tgt2) { + g.autoTarget = tgt2; + // only steal the crosshair when the player isn't actively aiming + if (!inp.firing) p.aim = Math.atan2(tgt2.y - p.y, tgt2.x - p.x); + } + } + + var wantFire = inp.firing || US.save.get().settings.autofire; + p.cooldown -= dt; + if (wantFire && p.cooldown <= 0) { + var st = p.dpsStats(); + p.cooldown = 1 / st.rof; + fireWeaponBullets(g, p, p.aim, 1); + } + } else if (g.mode === 'arena') { + g.playerRespawnT -= dt; + if (g.playerRespawnT <= 0) { + p.alive = true; + p.hp = p.maxHp || p.stats.maxHp; + p.hp = p.stats.maxHp; + p.invuln = 2; + p.x = util.rand(120, W - 120); + p.y = util.rand(120, H - 120); + boomFx(g, p.x, p.y, p.skin.body, 16, 160); + } + } + + // ---- combo decay ---- + if (g.comboTimer > 0) { + g.comboTimer -= dt; + if (g.comboTimer <= 0) g.combo = 0; + } + + // ---- heat decay / onfire ---- + if (!g.onFire) { + g.sinceKill += dt; + if (g.hangover > 0) g.hangover -= dt; + if (g.sinceKill > 1.3 && g.heat > 0) g.heat = Math.max(0, g.heat - 9 * dt); + } else { + g.onFireT -= dt; + g.heat = Math.max(0, 100 * (g.onFireT / 7)); + if (g.onFireT <= 0) endOnFire(g); + } + + // ---- campaign spawning ---- + if (g.mode === 'campaign') { + for (var q = 0; q < g.spawnQueue.length; q++) { + g.spawnQueue[q].t -= dt; + if (g.spawnQueue[q].t <= 0) { + var item = g.spawnQueue[q]; + var pos = spawnEdgePoint(g); + pos.x = util.clamp(pos.x, WALL + 30, W - WALL - 30); + pos.y = util.clamp(pos.y, WALL + 30, H - WALL - 30); + spawnEnemyAt(g, item.defId, pos.x, pos.y, item.mods); + g.spawnQueue.splice(q, 1); + q--; + } + } + var live = 0; + for (var li = 0; li < g.enemies.length; li++) if (g.enemies[li].alive && !g.enemies[li].isFighter) live++; + if (g.waveActive && g.spawnQueue.length === 0 && live === 0) { + g.waveActive = false; + onWaveClear(g); + } + } else { + updateBots(g, dt); + g.arenaTime += dt; + } + + // ---- enemies ---- + for (var ei = 0; ei < g.enemies.length; ei++) { + var e = g.enemies[ei]; + if (!e.alive) continue; + if (e.isFighter) { if (e.hitFlash > 0) e.hitFlash -= dt; continue; } + e.update(g, dt); + } + + // ---- dino-film footstep tremors ---- + updateBossStomps(g, dt); + + // ---- bullets ---- + for (var bi = 0; bi < g.bullets.length; bi++) { + var bl = g.bullets[bi]; + if (!bl.alive) continue; + bl.update(g, dt); + } + filterDead(g); + + // ---- rings (novas/bombs/explosions) ---- + for (var ri = g.rings.length - 1; ri >= 0; ri--) { + var rg = g.rings[ri]; + rg.life -= dt; + var prog = 1 - rg.life / rg.maxLife; + rg.r = rg.maxR * easeOut(prog); + if (rg.dmg > 0) { + for (var re = 0; re < g.enemies.length; re++) { + var ee = g.enemies[re]; + if (!ee.alive || rg.hit.indexOf(ee.eid) >= 0) continue; + if (ee.spawnAnim > 0 && !rg.nova) continue; + var dd = Math.hypot(ee.x - rg.x, ee.y - rg.y); + if (dd < rg.r + ee.radius) { + rg.hit.push(ee.eid); + if (ee.isFighter) { + hurtFighter(g, ee, rg.dmg, rg.nova ? 0 : rg.ownerEid || 0); + } else { + damageEnemy(g, ee, rg.dmg, false, true); + } + } + } + } + if (rg.life <= 0) g.rings.splice(ri, 1); + } + + // ---- pickups ---- + for (var pi = 0; pi < g.pickups.length; pi++) { + if (g.pickups[pi].alive) g.pickups[pi].update(g, dt); + } + + // ---- day/night cycle (60s; runs start at noon) ---- + var cyc = (g.time % 60) / 60; + var dl = Math.cos(cyc * Math.PI * 2); + g.daylight = util.clamp(dl, 0, 1); + g.darkness = (1 - g.daylight) * 0.46; + if (!g.wasNight && g.daylight < 0.12) { + g.wasNight = true; + announce(g, L({ en: '🌙 Night falls — beasts grow bolder', vi: '🌙 Đêm xuống — thú liều hơn' }, metaLang()), '#9fb4ff'); + } + if (g.wasNight && g.daylight > 0.5) { + g.wasNight = false; + announce(g, L({ en: '☀️ Dawn breaks', vi: '☀️ Trời sáng rồi' }, metaLang()), '#ffe4bd'); + } + + // ---- tropical weather front ---- + g.weatherT -= dt; + if (g.weather === 'clear') { + if (g.weatherT <= 0) { + g.weather = 'rain'; + g.weatherT = util.rand(14, 26); + announce(g, L({ en: '🌧️ Rain sweeps the valley', vi: '🌧️ Mưa trút xuống thung lũng' }, metaLang()), '#9fc4ff'); + } + } else { + // lightning roll while it pours + if (Math.random() < dt * 0.22 && g.thunderT < 0) { + g.flashWhite = 0.55; + g.thunderT = 0.18; + } + if (g.thunderT >= 0) { + g.thunderT -= dt; + if (g.thunderT <= 0) { US.audio.sfx('thunder'); g.thunderT = -1; } + } + if (g.weatherT <= 0) { + g.weather = 'clear'; + g.weatherT = util.rand(30, 60); + announce(g, L({ en: '🌤️ The rain moves on', vi: '🌤️ Mưa tạnh rồi' }, metaLang()), '#ffe4bd'); + } + } + if (g.flashWhite > 0) g.flashWhite -= dt * 2.2; + + // ---- ambient wilderness: distant calls every so often ---- + g.roarT -= dt; + if (g.roarT <= 0) { + g.roarT = util.rand(14, 30); + US.audio.distantRoar(); + } + + // ---- fx ---- + updateDecals(g, dt); + for (var prn = g.prints.length - 1; prn >= 0; prn--) { + g.prints[prn].life -= dt; + if (g.prints[prn].life <= 0) g.prints.splice(prn, 1); + } + US.entities.updateParticles(dt); + US.entities.updateFloaters(dt); + if (g.shake > 0) g.shake = Math.max(0, g.shake - 30 * dt); + if (g.flashRed > 0) g.flashRed -= dt; + } + + /* Jurassic-style ground tremor: every stride of an alpha shakes + the screen a little — harder the closer it stalks to you. */ + function updateBossStomps(g, dt) { + var p = g.player; + for (var i = 0; i < g.enemies.length; i++) { + var b = g.enemies[i]; + if (!b.alive || !b.isBoss || b.spawnAnim > 0 || g.state !== 'playing') continue; + + if (b.stepT === undefined) b.stepT = util.rand(0.15, 0.5); // desync twins + b.stepT -= dt; + if (b.stepT > 0) continue; + + var stride = util.clamp(48 / b.radius, 0.42, 0.72); + b.stepT = stride; + + // proximity: full force up close, faint rumble across the arena + var d = Math.hypot(p.x - b.x, p.y - b.y); + var prox = util.clamp(1 - d / 950, 0.22, 1); + + // tremor + g.shake = Math.min(g.shake + 2.6 * prox, 10); + + // dust burst under the striking foot + g.rings.push({ + x: b.x + util.rand(-b.radius * 0.5, b.radius * 0.5), + y: b.y + b.radius * 0.6, + r: 4, + maxR: 26 + b.radius * 0.5, + life: 0.32, maxLife: 0.32, + color: '#8f7448', dmg: 0, hit: [], stomp: true + }); + US.entities.spawnParticles(null, b.x, b.y + b.radius * 0.5, '#8f7448', 3, 1.1, 3); + + // deep thud, louder up close + if (prox > 0.3) US.audio.sfx('stomp', 0.06 + 0.16 * prox); + + g.stompCount++; + } + } + + function hurtFighter(g, f, dmg, byEid) { + if (!f.alive || f.spawnAnim > 0) return; + if (byEid === 0 && !g.player.alive) return; + f.hp -= dmg; + f.hitFlash = 0.1; + if (f.hp <= 0) { + f.lastHitBy = byEid; + if (byEid === 0) { + fighterDied(g, f, 'player'); + } else { + fighterDied(g, f, byEid); + } + } + } + + function easeOut(t) { return 1 - (1 - t) * (1 - t); } + + function clampToWorld(o) { + o.x = util.clamp(o.x, WALL + o.radius, W - WALL - o.radius); + o.y = util.clamp(o.y, WALL + o.radius, H - WALL - o.radius); + } + + function onWaveClear(g) { + var bonus = 15 + g.wave * 3; + g.runCoins += bonus; + g.score += 50 * g.wave; + announce(g, US.i18n.t('announce.waveClear'), '#8ac74a'); + US.entities.floatText(g.player.x, g.player.y - 40, '+' + bonus + ' amber', '#ffb938', true); + US.audio.sfx('win'); + // short breather then card pick + g.state = 'cards'; + setTimeout(function () { + var live = US.game && US.game.get ? US.game.get() : null; + if (live === g && !g.over && US.ui) US.ui.showCardPick(rollCards(g), function (card) { + if ((US.game.get() || null) !== g || g.over) return; + g.player.takeCard(card); + g.state = 'playing'; + scheduleNextWave(g); + }); + }, 900); + } + + function rollCards(g) { + var p = g.player; + var pool = US.CARDS.filter(function (c) { + var taken = p.cards.filter(function (id) { return id === c.id; }).length; + return taken < c.max; + }); + var out = []; + var weights = { common: 100, rare: 45, epic: 16 }; + while (out.length < 3 && pool.length) { + var total = 0; + pool.forEach(function (c) { total += weights[c.rarity]; }); + var roll = Math.random() * total; + var chosen = pool[0], acc = 0; + for (var i = 0; i < pool.length; i++) { + acc += weights[pool[i].rarity]; + if (roll <= acc) { chosen = pool[i]; break; } + } + out.push(chosen); + pool.splice(pool.indexOf(chosen), 1); + } + return out; + } + + function nearestEnemy(g, x, y, maxDist, excludeIds) { + var best = null, bd = maxDist * maxDist; + for (var i = 0; i < g.enemies.length; i++) { + var e = g.enemies[i]; + if (!e.alive || (excludeIds && excludeIds.indexOf(e.eid) >= 0)) continue; + if (e.spawnAnim > 0) continue; + var d2v = util.dist2(x, y, e.x, e.y); + if (d2v < bd) { bd = d2v; best = e; } + } + return best; + } + + function filterDead(g) { + var ne = []; + for (var i = 0; i < g.enemies.length; i++) { + var e = g.enemies[i]; + if (e.alive || e.isFighter) ne.push(e); // fighters stay listed (respawn logic) + } + g.enemies = ne; + var nb = []; + for (var j = 0; j < g.bullets.length; j++) if (g.bullets[j].alive) nb.push(g.bullets[j]); + g.bullets = nb; + var np = []; + for (var k = 0; k < g.pickups.length; k++) if (g.pickups[k].alive) np.push(g.pickups[k]); + g.pickups = np; + } + + function collectPickup(g, pk) { + var p = g.player; + if (pk.kind === 'coin') { + var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul)); + if (g.bloodMoon) amt *= 2; + g.runCoins += amt; + g.score += 2; + US.entities.floatText(pk.x, pk.y - 10, '+' + amt, '#ffb938'); + } else if (pk.kind === 'heal') { + p.hp = Math.min(p.stats.maxHp, p.hp + pk.amount); + US.entities.floatText(pk.x, pk.y - 10, '+' + pk.amount + ' HP', '#8affc1'); + US.audio.sfx('heal'); + } else if (pk.kind === 'bomb') { + p.bombs++; + US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d'); + } else if (pk.kind === 'egg') { + var names = ['Cơm Nếp', 'Xanh Lá', 'Bonnie', 'Mập Mạp', 'Tia Chớp', 'Bông']; + var cols = ['#c98a3d', '#5f8f3e', '#b05f8f', '#7a5230', '#58a55c', '#9fd8ff']; + var pi = (Math.random() * names.length) | 0; + US.save.get().pet = { name: names[pi], color: cols[pi] }; + US.save.save(); + g.pet = makePet(g); + announce(g, L({ + en: '🥚 A raptor hatched: "' + names[pi] + '"!', + vi: '🥚 Raptor con nở rồi: "' + names[pi] + '"!' + }, metaLang()), '#ffd24a'); + US.audio.sfx('win'); + checkAch(g); + } + if (pk.kind === 'coin') { + US.save.stat('totalAmber', 1); + questProgress(g, 'collect', 1); + checkAch(g); + } + US.audio.sfx('pickup'); + } + + /* ================================================================ + Rendering + ================================================================ */ + function render(g, ctx, vw, vh) { + var scale = Math.min(vw / W, vh / H); + var ox = (vw - W * scale) / 2; + var oy = (vh - H * scale) / 2; + var shx = 0, shy = 0; + var sSet = US.save.get().settings; + var sAmt = (!sSet.shake) ? 0 : (sSet.shakeAmt == null ? 1 : sSet.shakeAmt); + if (sAmt > 0 && g.shake > 0) { + shx = util.rand(-g.shake, g.shake) * sAmt; + shy = util.rand(-g.shake, g.shake) * sAmt; + } + + ctx.fillStyle = '#07060d'; + ctx.fillRect(0, 0, vw, vh); + ctx.save(); + ctx.translate(ox + shx, oy + shy); + ctx.scale(scale, scale); + + drawArena(g, ctx); + + // blood stains from past kills + fresh footprints + biome ground fx + drawDecals(ctx, g); + drawPrints(ctx, g); + US.art.drawBiomeFx(ctx, g); + + // rings under everything + for (var i = 0; i < g.rings.length; i++) drawRing(ctx, g.rings[i]); + + // pickups + for (var pk = 0; pk < g.pickups.length; pk++) drawPickup(ctx, g.pickups[pk]); + + // enemies / bots + for (var ei = 0; ei < g.enemies.length; ei++) { + var e = g.enemies[ei]; + if (!e.alive) continue; + if (e.isFighter) drawBot(ctx, e); + else if (e.isBoss) drawBoss(ctx, e); + else drawEnemy(ctx, e); + } + + // bullets + for (var bi = 0; bi < g.bullets.length; bi++) drawBullet(ctx, g.bullets[bi]); + + // player + if (g.player.alive) drawPlayer(ctx, g.player, g); + + US.entities.drawParticles(ctx); + US.entities.drawFloaters(ctx); + + // drifting sky: cloud shadows, circling vulture, floating ash + if (sSet.quality !== 'low') { + US.art.drawCloudShadows(ctx, W, H, g.time); + US.art.drawVultureShadow(ctx, W, H, g.time); + US.art.drawAshMotes(ctx, W, H, g.time); + US.art.drawNight(ctx, W, H, g); + if (g.weather === 'rain') US.art.drawRain(ctx, W, H, g.time, 1); + if (g.bloodMoon) { + ctx.fillStyle = 'rgba(150,18,18,' + (0.07 + (g.darkness || 0) * 0.05).toFixed(3) + ')'; + ctx.fillRect(0, 0, W, H); + } + } + + ctx.restore(); + + drawHud(g, ctx, vw, vh, scale, ox, oy); + + if (g.flashRed > 0) { + ctx.fillStyle = 'rgba(255,46,80,' + (g.flashRed * 0.8) + ')'; + ctx.fillRect(0, 0, vw, vh); + } + if (g.flashWhite > 0) { + ctx.fillStyle = 'rgba(240,246,255,' + (g.flashWhite * 0.85).toFixed(3) + ')'; + ctx.fillRect(0, 0, vw, vh); + } + } + + function drawArena(g, ctx) { + US.art.drawGround(g, ctx); + } + + function glowShape(ctx, color, blur) { + ctx.shadowColor = color; + ctx.shadowBlur = blur || 14; + } + + function drawPlayer(ctx, p, g) { + if (!p.alive) return; + ctx.save(); + ctx.translate(p.x, p.y); + + // on-fire aura (gameplay juice stays here) + if (g.onFire) { + var pulse = 1 + Math.sin(g.time * 14) * 0.15; + ctx.strokeStyle = 'rgba(255,138,61,0.85)'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, 24 * pulse, 0, Math.PI * 2); + ctx.stroke(); + } else if (g.hangover > 0) { + ctx.strokeStyle = 'rgba(156,138,114,0.35)'; + ctx.setLineDash([4, 6]); + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, 23, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // spawn shield + if (p.invuln > 0) { + ctx.strokeStyle = 'rgba(232,220,196,' + (0.25 + 0.2 * Math.sin(g.time * 20)) + ')'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, 27, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + + US.art.drawHuman(ctx, { + x: p.x, y: p.y, + radius: p.radius, + aim: p.aim, + walkPhase: p.walkPhase || 0, + moving: !!p.moving, + color: p.skin.body, + body: p.skin.body, + trim: p.skin.trim, + weaponKind: p.weapon.kind, + hurtT: p.hurtT > 0 ? p.hurtT : 0, + fireT: p.fireT > 0 ? p.fireT : 0, + scale: 1.18, + torch: (g.darkness || 0) > 0.22, + seedF: 1.3 + }, g.time); + } + + function drawEnemy(ctx, e) { + if (e.spawnAnim > 0) { + ctx.save(); + ctx.translate(e.x, e.y); + ctx.strokeStyle = e.color; + ctx.globalAlpha = 0.7; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, e.radius * (1.6 - e.spawnAnim), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + var art = (e.def && e.def.art) || 'raptor'; + switch (art) { + case 'trike': US.art.drawTriceratops(ctx, e, g_time(e)); break; + case 'dilo': US.art.drawDilo(ctx, e, g_time(e)); break; + case 'stego': US.art.drawStego(ctx, e, g_time(e)); break; + case 'pachy': US.art.drawPachy(ctx, e, g_time(e)); break; + case 'ptero': US.art.drawPtero(ctx, e, g_time(e), false); break; + case 'pteroChick': US.art.drawPtero(ctx, e, g_time(e), true); break; + case 'alphaRaptor': + drawAlphaRaptor(ctx, e); + break; + default: + US.art.drawRaptor(ctx, e, g_time(e), { alpha: false }); + } + if (e.elite && art !== 'alphaRaptor') { + ctx.strokeStyle = '#ffb938'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius + 5, 0, Math.PI * 2); + ctx.stroke(); + } + } + + function drawAlphaRaptor(ctx, e) { + // alpha = raptor painter at full scale + crown ring handled inside opts + US.art.drawRaptor(ctx, e, g_time(e), { alpha: true }); + ctx.strokeStyle = '#ffb938'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius + 7, 0, Math.PI * 2); + ctx.stroke(); + } + + function g_time() { + return performance.now() / 1000; + } + + function drawBoss(ctx, b) { + if (b.spawnAnim > 0) { + ctx.save(); + ctx.translate(b.x, b.y); + ctx.strokeStyle = b.color; + ctx.globalAlpha = 0.8; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(0, 0, b.radius * (2 - b.spawnAnim), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + var art = (b.bossDef && b.bossDef.art) || 'rex'; + if (art === 'spino') US.art.drawSpino(ctx, b, g_time(b)); + else if (art === 'giga') US.art.drawGiga(ctx, b, g_time(b)); + else US.art.drawRex(ctx, b, g_time(b)); + } + + function drawBot(ctx, f) { + if (!f.alive) return; + if (f.spawnAnim > 0) { + ctx.save(); + ctx.translate(f.x, f.y); + ctx.strokeStyle = f.color; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.arc(0, 0, f.radius * (1.8 - f.spawnAnim * 2), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + US.art.drawHuman(ctx, { + x: f.x, y: f.y, + radius: f.radius, + aim: f.face || 0, + walkPhase: f.walkPhase || 0, + moving: true, + color: f.color, + trim: US.art.shade(f.color, 40), + weaponKind: f.weapon.kind, + label: f.name.slice(0, 4), + hurtT: f.hitFlash > 0 ? f.hitFlash * 3 : 0, + seedF: f.eid || 0 + }, g_time(f)); + // hp bar under the name + if (f.hp < f.maxHp) { + ctx.fillStyle = 'rgba(20,14,8,0.72)'; + ctx.fillRect(f.x - 14, f.y - f.radius - 8, 28, 4); + ctx.fillStyle = f.color; + ctx.fillRect(f.x - 14, f.y - f.radius - 8, 28 * Math.max(0, f.hp / f.maxHp), 4); + } + } + + function drawBullet(ctx, b) { + US.art.drawBullet(ctx, b); + } + + function drawPickup(ctx, pk) { + var blink = pk.life < 2.5 && Math.floor(pk.life * 8) % 2 === 0; + US.art.drawPickup(ctx, pk, !blink); + } + + function drawRing(ctx, rg) { + var alpha = rg.life / rg.maxLife; + ctx.strokeStyle = rg.color; + ctx.globalAlpha = alpha * 0.9; + ctx.lineWidth = rg.nova || rg.bomb ? 8 : 4; + glowShape(ctx, rg.color, 20); + ctx.beginPath(); + ctx.arc(rg.x, rg.y, rg.r, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + } + + /* ---------------- HUD ---------------- */ + function drawHud(g, ctx, vw, vh, scale, ox, oy) { + ctx.save(); + // HUD anchored to the scaled play area + ctx.translate(ox, oy); + var W2 = vw / scale, H2 = vh / scale; + var pad = 18; + var p = g.player; + + /* ---------- shared bits ---------- */ + function rrect(x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + function panel(x, y, w, h) { + ctx.fillStyle = 'rgba(14,9,5,0.74)'; + rrect(x, y, w, h, 10); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,196,130,0.26)'; + ctx.lineWidth = 1.5; + rrect(x, y, w, h, 10); + ctx.stroke(); + } + function txt(str, x, y, size, col, align, bold) { + ctx.fillStyle = col || '#f3e7cf'; + ctx.font = (bold ? 'bold ' : '') + size + 'px system-ui, sans-serif'; + ctx.textAlign = align || 'left'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = 'rgba(0,0,0,0.7)'; + ctx.shadowBlur = 3; + ctx.fillText(str, x, y); + ctx.shadowBlur = 0; + } + + // soft scrim so panels pop against bright ground + var scrim = ctx.createLinearGradient(0, 0, 0, 120); + scrim.addColorStop(0, 'rgba(10,6,3,0.5)'); + scrim.addColorStop(1, 'rgba(10,6,3,0)'); + ctx.fillStyle = scrim; + ctx.fillRect(0, 0, W2, 120); + + /* ---------- top-left: vitals card ---------- */ + var cw = 330, ch = 96; + panel(pad, pad, cw, ch); + var cx0 = pad + 14, cy0 = pad + 14, cwid = cw - 28; + + // HP row + txt('HP', cx0, cy0 + 8, 11, 'rgba(243,231,207,0.75)', 'left', true); + var hpPct = p.hp / p.stats.maxHp; + var hx = cx0 + 30, hy = cy0, hw = cwid - 30, hh = 17; + ctx.fillStyle = '#241408'; + rrect(hx, hy, hw, hh, 5); ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; + rrect(hx, hy, hw, hh, 5); ctx.stroke(); + var hpCol = hpPct > 0.55 ? '#7fb84a' : (hpPct > 0.3 ? '#e8a33d' : '#e04b3a'); + ctx.save(); + rrect(hx, hy, Math.max(hh * 0.9, hw * Math.max(0, hpPct)), hh, 5); + ctx.clip(); + var hg = ctx.createLinearGradient(hx, hy, hx, hy + hh); + hg.addColorStop(0, hpCol); + hg.addColorStop(1, 'rgba(0,0,0,0.35)'); + ctx.fillStyle = hg; + ctx.fillRect(hx, hy, hw, hh); + ctx.restore(); + txt(Math.ceil(p.hp) + ' / ' + p.stats.maxHp, hx + hw / 2, hy + hh / 2 + 1, 12, '#fff', 'center', true); + + // heat / ON FIRE row + var ty = cy0 + 25, th = 13; + var fireW = hw * (g.heat / 100); + if (g.onFire) fireW += Math.sin(g.time * 12) * 3; + ctx.fillStyle = '#241408'; + rrect(cx0, ty, hw, th, 4); ctx.fill(); + ctx.save(); + rrect(cx0, ty, Math.max(th * 0.8, Math.min(hw, fireW)), th, 4); + ctx.clip(); + ctx.fillStyle = g.onFire ? '#ffb43d' : '#ff8a3d'; + ctx.fillRect(cx0, ty, hw, th); + ctx.restore(); + txt( + g.onFire ? US.i18n.t('hud.onFire') : (g.hangover > 0 ? US.i18n.t('announce.hangover') : 'HEAT'), + cx0 + hw / 2, ty + th / 2 + 1, 10, + g.onFire ? '#1c130a' : '#ffe4bd', 'center', true); + + // weapon + bombs row + var wy = cy0 + 50; + var wname = (p.weapon && p.weapon.name ? p.weapon.name : '').toUpperCase(); + txt(wname, cx0, wy + 6, 11, '#e8dcc4', 'left', true); + var maxShow = Math.min(p.bombs, 5); + for (var bi = 0; bi < maxShow; bi++) { + var bxc = cx0 + cwid - 10 - bi * 19; + ctx.fillStyle = '#3a2a16'; + ctx.beginPath(); ctx.arc(bxc, wy + 6, 6, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,196,130,0.4)'; ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStyle = '#ffb938'; + ctx.beginPath(); ctx.arc(bxc - 2, wy + 4, 1.6, 0, Math.PI * 2); ctx.fill(); + } + if (p.bombs > 5) txt('+' + (p.bombs - 5), cx0 + cwid - 10 - 5 * 19 - 8, wy + 6, 11, '#ff8a3d', 'right', true); + + /* ---------- top-center: wave banner / arena board ---------- */ + ctx.textBaseline = 'middle'; + if (g.mode === 'campaign') { + var midX = W2 / 2; + if (g.waveActive) { + var dnIco = (g.darkness || 0) > 0.35 ? '🌙 ' : '☀️ '; + txt(dnIco + US.i18n.t('hud.wave') + ' ' + g.wave, midX, pad + 16, 26, '#fff', 'center', true); + var remaining = g.enemies.filter(function (e) { return e.alive && !e.isFighter && !e.isBoss; }).length + g.spawnQueue.length; + if (!isBossAlive(g)) { + ctx.fillStyle = 'rgba(14,9,5,0.66)'; + rrect(midX - 62, pad + 32, 124, 20, 10); ctx.fill(); + txt(remaining + ' ' + US.i18n.t('hud.left'), midX, pad + 42, 12, '#e8dcc4', 'center'); + } + } + // full-size boss bar + var boss = getBoss(g); + if (boss) { + var bbw = Math.min(720, W2 - 160), bbh = 21; + var bx = (W2 - bbw) / 2, by = pad + 58; + panel(bx - 5, by - 5, bbw + 10, bbh + 10); + ctx.fillStyle = '#301410'; + rrect(bx, by, bbw, bbh, 6); ctx.fill(); + var bp = Math.max(0, boss.hp / boss.maxHp); + ctx.save(); + rrect(bx, by, Math.max(bbh, bbw * bp), bbh, 6); + ctx.clip(); + ctx.fillStyle = boss.color; + ctx.fillRect(bx, by, bbw, bbh); + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.fillRect(bx, by + bbh * 0.55, bbw, bbh * 0.45); + ctx.restore(); + ctx.strokeStyle = 'rgba(255,196,130,0.4)'; + ctx.lineWidth = 1.5; + rrect(bx, by, bbw, bbh, 6); ctx.stroke(); + txt(boss.bossDef.name.toUpperCase() + ' · ' + US.i18n.t('hud.boss'), + bx + bbw / 2, by + bbh / 2 + 1, 13, '#fff', 'center', true); + txt(Math.ceil(bp * 100) + '%', bx + bbw - 12, by + bbh / 2 + 1, 12, '#ffe4bd', 'right', true); + } + } else { + // arena scoreboard card + var rows = [{ name: US.i18n.t('arena.you'), kills: g.kills, color: p.skin.body }].concat( + g.bots.map(function (b) { return { name: b.name, kills: b.kills, color: b.color }; }) + ); + rows.sort(function (a, b2) { return b2.kills - a.kills; }); + var abw = 250, abh = rows.length * 24 + 38; + var ax = W2 / 2 - abw / 2; + panel(ax, pad, abw, abh); + var sy = pad + 20; + for (var ri = 0; ri < rows.length; ri++) { + var row = rows[ri]; + txt((ri + 1) + '.', ax + 16, sy, 13, 'rgba(243,231,207,0.6)', 'left', true); + txt(row.name, ax + 36, sy, 14, row.color, 'left', true); + txt(String(row.kills), ax + abw - 16, sy, 15, '#fff', 'right', true); + sy += 24; + } + txt(US.i18n.t('arena.leader', { n: g.targetKills }), W2 / 2, sy + 2, 11, 'rgba(232,220,196,0.7)', 'center'); + } + + /* ---------- top-right: economy card + combo ---------- */ + var ew = 172, eh = g.bloodMoon ? 80 : 62; + var ex = W2 - pad - ew; + panel(ex, pad, ew, eh); + // amber nugget icon + ctx.fillStyle = '#ffb938'; + ctx.beginPath(); ctx.arc(ex + 20, pad + 18, 7, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.beginPath(); ctx.arc(ex + 17, pad + 15, 2.2, 0, Math.PI * 2); ctx.fill(); + txt('$' + util.fmt(g.runCoins), ex + 34, pad + 18, 18, '#ffb938', 'left', true); + txt(util.fmt(g.score) + ' PTS', ex + 20, pad + 44, 13, '#e8dcc4', 'left', true); + if (g.bloodMoon) txt('🌑 ×2 AMBER', ex + 20, pad + 66, 12, '#ff8f8f', 'left', true); + + if (g.combo >= 5) { + var pulseC = 1 + Math.sin(g.time * 10) * 0.05; + var ccx = W2 - pad - ew / 2, ccy = pad + eh + 22; + ctx.save(); + ctx.translate(ccx, ccy); + ctx.scale(pulseC, pulseC); + ctx.fillStyle = '#ff8a3d'; + rrect(-46, -14, 92, 28, 14); ctx.fill(); + ctx.strokeStyle = '#ffd24a'; ctx.lineWidth = 2; + rrect(-46, -14, 92, 28, 14); ctx.stroke(); + txt('COMBO ×' + g.combo, 0, 1, 15, '#1c130a', 'center', true); + ctx.restore(); + } + + /* ---------- bottom-right: ult dial ---------- */ + var ur = 30; + var ux = W2 - pad - ur - 6, uy = H2 - pad - ur - 6; + ctx.fillStyle = 'rgba(14,9,5,0.74)'; + ctx.beginPath(); ctx.arc(ux, uy, ur, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,196,130,0.26)'; ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.14)'; + ctx.lineWidth = 5; + ctx.beginPath(); ctx.arc(ux, uy, ur - 6, 0, Math.PI * 2); ctx.stroke(); + if (p.ult > 0) { + if (p.ult >= 100) glowShape(ctx, p.skin.body, 14); + ctx.strokeStyle = p.ult >= 100 ? '#ffd24a' : p.skin.body; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.arc(ux, uy, ur - 6, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, p.ult / 100)); + ctx.stroke(); + ctx.lineCap = 'butt'; + ctx.shadowBlur = 0; + } + if (p.ult >= 100) { + var pr2 = ur + 4 + Math.sin(g.time * 6) * 2; + ctx.strokeStyle = 'rgba(255,210,74,' + (0.4 + 0.3 * Math.sin(g.time * 6)).toFixed(2) + ')'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(ux, uy, pr2, 0, Math.PI * 2); ctx.stroke(); + txt(US.i18n.t('hud.ultReady'), ux, uy - ur - 14, 12, '#ffd24a', 'center', true); + txt('★', ux, uy + 1, 18, '#fff6ea', 'center', true); + } else { + txt(Math.floor(p.ult) + '%', ux, uy + 1, 13, 'rgba(255,255,255,0.85)', 'center', true); + } + + // danger vignette when badly hurt + var hpr = p.hp / p.stats.maxHp; + if (p.alive && hpr < 0.3) { + var va = Math.max(0, (0.32 - hpr)) * (0.85 + 0.35 * Math.sin(g.time * 6)); + var vg = ctx.createRadialGradient( + W2 / 2, H2 / 2, H2 * 0.34, + W2 / 2, H2 / 2, H2 * 0.78); + vg.addColorStop(0, 'rgba(150,18,8,0)'); + vg.addColorStop(1, 'rgba(150,18,8,' + va.toFixed(3) + ')'); + ctx.fillStyle = vg; + ctx.fillRect(0, 0, W2, H2); + } + + ctx.restore(); + } + + function getBoss(g) { + for (var i = 0; i < g.enemies.length; i++) { + if (g.enemies[i].alive && g.enemies[i].isBoss) return g.enemies[i]; + } + return null; + } + + function isBossAlive(g) { return !!getBoss(g); } + + /* ================================================================ + Public API + loop + ================================================================ */ + var current = null; + var raf = null; + var lastT = 0; + var canvas = null; + + US.game = { + get: function () { return current; }, + start: function (mode, opts) { + current = newRun(mode, opts); + US.audio.resume(); + US.audio.startMusic(); + return current; + }, + stop: function () { + current = null; + US.entities.clearParticles(); + US.entities.clearFloaters(); + US.audio.stopMusic(); + // NOTE: the RAF loop itself stays alive — it renders the attract + // backdrop while no run is active, so the next start() needs no remount. + }, + pause: function () { if (current && current.state === 'playing') { current.state = 'paused'; return true; } return false; }, + resume: function () { if (current && current.state === 'paused') { current.state = 'playing'; return true; } return false; }, + castUlt: function () { if (current) castUlt(current); }, + ensureQuests: function () { ensureQuests(US.save.get()); }, + jumpWave: function (n) { + if (!current || current.state === 'over') return false; + current.state = 'playing'; + current.wave = (n | 0) - 1; + scheduleNextWave(current); + return true; + }, + useBomb: function () { if (current) useBomb(current); }, + acceptRevive: function () { + if (current && current.state === 'revive') { acceptRevive(current); if (US.ui) US.ui.hideRevive(); } + }, + declineRevive: function () { + if (current && current.state === 'revive') { endRun(current); if (US.ui) US.ui.hideRevive(); } + }, + isBossWave: function () { return current ? current.wave % 5 === 0 && current.waveActive : false; }, + mount: function (cv) { + canvas = cv; + if (raf) cancelAnimationFrame(raf); + lastT = performance.now(); + function frame(t) { + raf = requestAnimationFrame(frame); + var dt = Math.min(0.05, (t - lastT) / 1000); + lastT = t; + var ctx = cv.getContext('2d'); + var dpr = Math.min(window.devicePixelRatio || 1, 2); + var vw = cv.clientWidth, vh = cv.clientHeight; + if (cv.width !== vw * dpr || cv.height !== vh * dpr) { + cv.width = vw * dpr; cv.height = vh * dpr; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (current) { + update(current, dt); + render(current, ctx, vw, vh); + } else { + // painted dusk scene behind the menus + US.art.drawMenuScene(ctx, vw, vh, t / 1000); + } + } + raf = requestAnimationFrame(frame); + }, + // internals exposed for testing + _internals: { + waveBudget: waveBudget, + rollCards: function () { return current ? rollCards(current) : []; }, + addHeat: function (v) { if (current) addHeat(current, v); }, + REVIVE_COST: REVIVE_COST + } + }; + + // wire entity callbacks that need game context + US.entities._hooks = { + nearestEnemy: function (x, y, d, ex) { return current ? nearestEnemy(current, x, y, d, ex) : null; }, + toggleAutoAim: function () { + var st = US.save.get(); + st.settings.autoAim = !st.settings.autoAim; + US.save.save(); + return st.settings.autoAim; + }, + damageEnemy: function (e, dmg, crit, silent) { if (current) damageEnemy(current, e, dmg, crit, silent); }, + explode: function (x, y, r, dmg) { if (current) explodeAt(current, x, y, r, dmg); }, + collect: function (pk) { if (current) collectPickup(current, pk); }, + enemyBullet: function (x, y, nx, ny, s, dmg, c) { if (current) enemyBullet(current, x, y, nx, ny, s, dmg, c); }, + tarSlow: function (x, y) { return current ? inTar(current, x, y) : false; }, + nightFast: function () { return !!(current && current.darkness > 0.45); }, + spawnEnemy: function (id, x, y, m) { if (current) spawnEnemyAt(current, id, x, y, m); }, + splitBoss: function (b) { if (current) splitBoss(current, b); }, + fireWeapon: function (shooter, ang, scale) { if (current) fireWeaponBullets(current, shooter, ang, scale); } + }; +})(); diff --git a/js/i18n.js b/js/i18n.js new file mode 100644 index 0000000..a5f3b46 --- /dev/null +++ b/js/i18n.js @@ -0,0 +1,257 @@ +/* Neon Rampage — i18n (EN / VI). Original strings. */ +(function () { + 'use strict'; + + var DICT = { + en: { + 'menu.title': 'NEON RAMPAGE', + 'menu.subtitle': 'Stone-age survival — dinosaur waves, alphas, cards & arena', + 'menu.campaign': 'CAMPAIGN', + 'menu.arena': 'ARENA (vs Bots)', + 'menu.shop': 'SHOP', + 'menu.heroes': 'HEROES', + 'menu.rank': 'LEADERBOARD', + 'menu.settings': 'SETTINGS', + 'menu.howto': 'HOW TO PLAY', + 'menu.coins': 'Coins', + 'menu.bestScore': 'Best score', + 'menu.bestWave': 'Best wave', + + 'how.title': 'How to play', + 'how.move': 'Move — WASD / Arrows (or left stick)', + 'how.aim': 'Aim — mouse (or right stick), hold to fire', + 'how.ult': 'Ultimate — R when the nova is charged', + 'how.bomb': 'Bomb — Q, wipes bullets & blasts everything', + 'how.pause': 'Pause — P or Esc', + 'how.onfire': 'Keep hunting to fill the fire bar and go ON FIRE: double damage for a few seconds. Hesitate too long and you hit the hangover.', + 'how.cards': 'Clear a wave, pick 1 of 3 cards. An alpha dinosaur every 5th wave.', + 'how.close': 'Back', + + 'shop.tabWeapons': 'WEAPONS', + 'shop.tabHeroes': 'HEROES', + 'shop.tabSkins': 'SKINS', + 'shop.owned': 'OWNED', + 'shop.buy': 'BUY', + 'shop.equip': 'EQUIP', + 'shop.equipped': 'EQUIPPED', + 'shop.notEnough': 'Not enough coins', + 'shop.bought': 'Purchased!', + 'shop.locked': 'Locked', + 'shop.bombs': 'Spare bombs', + + 'hero.talent': 'Talent', + 'hero.statHp': 'HP', + 'hero.statSpeed': 'Speed', + 'hero.statDmg': 'Damage', + + 'hud.wave': 'WAVE', + 'hud.left': 'left', + 'hud.boss': 'TRÙM', + 'hud.onFire': 'ON FIRE!', + 'hud.ultReady': 'ULT READY [R]', + 'hud.score': 'SCORE', + 'hud.paused': 'PAUSED', + 'hud.resume': 'RESUME', + 'hud.quit': 'QUIT TO MENU', + + 'announce.wave': 'WAVE', + 'announce.boss': 'ALPHA DINOSAUR INCOMING', + 'announce.waveClear': 'WAVE CLEAR', + 'announce.onFire': 'ON FIRE!', + 'announce.hangover': 'HANGOVER…', + 'announce.go': 'GO!', + + 'card.pickTitle': 'CHOOSE A CARD', + 'card.pickHint': 'The next wave starts after you pick', + 'card.rarityCommon': 'COMMON', + 'card.rarityRare': 'RARE', + 'card.rarityEpic': 'EPIC', + + 'over.title': 'YOU WERE EATEN', + 'over.win': 'VICTORY!', + 'over.score': 'Score', + 'over.wave': 'Wave reached', + 'over.kills': 'Kills', + 'over.combo': 'Best combo', + 'over.coinsEarned': 'Coins earned', + 'over.newRecord': 'NEW RECORD!', + 'over.retry': 'PLAY AGAIN', + 'over.menu': 'MENU', + 'over.revive': 'REVIVE', + 'over.reviveHint': 'Revive once with full HP', + + 'arena.setup': 'ARENA SETUP', + 'arena.bots': 'Bots', + 'arena.target': 'Target kills', + 'arena.start': 'FIGHT', + 'arena.back': 'BACK', + 'arena.you': 'YOU', + 'arena.leader': 'FIRST TO {n} KILLS', + 'arena.timeLeft': 'Time', + + 'set.title': 'SETTINGS', + 'set.language': 'Language', + 'set.sfx': 'SFX volume', + 'set.music': 'Music volume', + 'set.autofire': 'Auto-fire', + 'set.autoaim': 'Auto-aim nearest (F)', + 'set.onehit': 'One-hit mode (campaign)', + 'set.shake': 'Screen shake', + 'set.name': 'Player name', + 'set.reset': 'Reset profile', + 'set.resetConfirm': 'Erase all progress? This cannot be undone.', + 'set.done': 'DONE', + + 'rank.title': 'LEADERBOARD', + 'rank.colPlayer': 'Player', + 'rank.colScore': 'Score', + 'rank.colWave': 'Wave', + 'rank.empty': 'No runs yet — go make a mess.', + + 'toast.notEnough': 'Not enough coins', + 'toast.saved': 'Saved', + 'common.ok': 'OK', + 'common.cancel': 'CANCEL' + }, + + vi: { + 'menu.title': 'NEON RAMPAGE', + 'menu.subtitle': 'Sinh tồn thời tiền sử — khủng long, trùm, thẻ bài & đấu trường', + 'menu.campaign': 'CHIẾN DỊCH', + 'menu.arena': 'ĐẤU TRƯỜNG (vs Bot)', + 'menu.shop': 'CỬA HÀNG', + 'menu.heroes': 'TƯỚNG', + 'menu.rank': 'BẢNG XẾP HẠNG', + 'menu.settings': 'CÀI ĐẶT', + 'menu.howto': 'HƯỚNG DẪN', + 'menu.coins': 'Xu', + 'menu.bestScore': 'Điểm cao nhất', + 'menu.bestWave': 'Wave cao nhất', + + 'how.title': 'Cách chơi', + 'how.move': 'Di chuyển — WASD / Phím mũi tên (hoặc trái cần)', + 'how.aim': 'Ngắm — chuột (hoặc phải cần), giữ để bắn', + 'how.ult': 'Chiêu cuối — R khi nổ tung đã đầy', + 'how.bomb': 'Bom — Q, xoá đạn & nổ tung mọi thứ', + 'how.pause': 'Tạm dừng — P hoặc Esc', + 'how.onfire': 'Săn liên tục để đầy thanh lửa và bùng cháy ON FIRE: sát thương x2 vài giây. Nghỉ quá lâu sẽ dính đau đầu.', + 'how.cards': 'Sống sót qua wave chọn 1 trong 3 thẻ. Mỗi wave 5 có KHỦNG LONG TRÙM.', + 'how.close': 'Quay lại', + + 'shop.tabWeapons': 'SÚNG', + 'shop.tabHeroes': 'TƯỚNG', + 'shop.tabSkins': 'TRANG PHỤC', + 'shop.owned': 'ĐÃ CÓ', + 'shop.buy': 'MUA', + 'shop.equip': 'TRANG BỊ', + 'shop.equipped': 'ĐANG DÙNG', + 'shop.notEnough': 'Không đủ xu', + 'shop.bought': 'Đã mua!', + 'shop.locked': 'Đã khoá', + 'shop.bombs': 'Bom dự trữ', + + 'hero.talent': 'Năng lực', + 'hero.statHp': 'Máu', + 'hero.statSpeed': 'Tốc độ', + 'hero.statDmg': 'Sát thương', + + 'hud.wave': 'WAVE', + 'hud.left': 'còn lại', + 'hud.boss': 'TRÙM', + 'hud.onFire': 'BẮT LỬA!', + 'hud.ultReady': 'CHIÊU CUỐI SẴN SÒNG [R]', + 'hud.score': 'ĐIỂM', + 'hud.paused': 'TẠM DỪNG', + 'hud.resume': 'TIẾP TỤC', + 'hud.quit': 'THOÁT VỀ MENU', + + 'announce.wave': 'WAVE', + 'announce.boss': 'KHỦNG LONG TRÙM XUẤT HIỆN', + 'announce.waveClear': 'WAVE SỐNG SÓT', + 'announce.onFire': 'BẮT LỬA!', + 'announce.hangover': 'ĐAU ĐẦU…', + 'announce.go': 'CHIẾN!', + + 'card.pickTitle': 'CHỌN MỘT THẺ', + 'card.pickHint': 'Wave mới bắt đầu sau khi bạn chọn', + 'card.rarityCommon': 'THƯỜNG', + 'card.rarityRare': 'QUÝ', + 'card.rarityEpic': 'SỬ THI', + + 'over.title': 'BỊ NUỐT RỒI', + 'over.win': 'CHIẾN THẮNG!', + 'over.score': 'Điểm', + 'over.wave': 'Wave đạt được', + 'over.kills': 'Hạ gục', + 'over.combo': 'Combo tốt nhất', + 'over.coinsEarned': 'Xu kiếm được', + 'over.newRecord': 'KỶ LỤC MỚI!', + 'over.retry': 'CHƠI LẠI', + 'over.menu': 'VỀ MENU', + 'over.revive': 'HỒI SINH', + 'over.reviveHint': 'Hồi sinh 1 lần với đầy máu', + + 'arena.setup': 'CHUẨN BỊ ĐẤU TRƯỜNG', + 'arena.bots': 'Số bot', + 'arena.target': 'Số hạ gục cần đạt', + 'arena.start': 'CHIẾN THÔI', + 'arena.back': 'QUAY LẠI', + 'arena.you': 'BẠN', + 'arena.leader': 'ĐẠT {n} HẠ GỤC ĐỂ THẮNG', + 'arena.timeLeft': 'Thời gian', + + 'set.title': 'CÀI ĐẶT', + 'set.language': 'Ngôn ngữ', + 'set.sfx': 'Âm lượng hiệu ứng', + 'set.music': 'Âm lượng nhạc', + 'set.autofire': 'Tự động bắn', + 'set.autoaim': 'Tự ngắm mục tiêu gần nhất (F)', + 'set.onehit': 'Chế độ một-hit (chiến dịch)', + 'set.shake': 'Rung màn hình', + 'set.name': 'Tên người chơi', + 'set.reset': 'Xoá toàn bộ dữ liệu', + 'set.resetConfirm': 'Xoá toàn bộ tiến trình? Không thể hoàn tác.', + 'set.done': 'XONG', + + 'rank.title': 'BẢNG XẾP HẠNG', + 'rank.colPlayer': 'Người chơi', + 'rank.colScore': 'Điểm', + 'rank.colWave': 'Wave', + 'rank.empty': 'Chưa có lần nào — ra trận đi!', + + 'toast.notEnough': 'Không đủ xu', + 'toast.saved': 'Đã lưu', + 'common.ok': 'ĐỒNG Ý', + 'common.cancel': 'HUỶ' + } + }; + + var US = (window.US = window.US || {}); + var lang = 'en'; + + US.i18n = { + setLang: function (l) { + lang = DICT[l] ? l : 'en'; + }, + getLang: function () { + return lang; + }, + t: function (key, vars) { + var s = (DICT[lang] && DICT[lang][key]) || DICT.en[key] || key; + if (vars) { + Object.keys(vars).forEach(function (k) { + s = s.replace('{' + k + '}', String(vars[k])); + }); + } + return s; + }, + // Apply to all elements carrying data-i18n="key" + apply: function () { + var els = document.querySelectorAll('[data-i18n]'); + for (var i = 0; i < els.length; i++) { + els[i].textContent = US.i18n.t(els[i].getAttribute('data-i18n')); + } + document.documentElement.lang = lang; + } + }; +})(); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..f6272db --- /dev/null +++ b/js/main.js @@ -0,0 +1,185 @@ +/* Neon Rampage — bootstrap & input (keyboard, mouse, touch sticks). */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + + var input = { + up: false, down: false, left: false, right: false, + firing: false, + aimX: 640, aimY: 360, + moveVec: null // touch stick vector {x,y} + }; + US.input = input; + + var canvas, touchUI = {}; + var stickMove = null, stickAim = null; // active touches + + function toWorld(e) { + var r = canvas.getBoundingClientRect(); + var g = US.game.get(); + if (!g) return { x: input.aimX, y: input.aimY }; + var scale = Math.min(r.width / g.W, r.height / g.H); + var ox = (r.width - g.W * scale) / 2; + var oy = (r.height - g.H * scale) / 2; + return { + x: (e.clientX - r.left - ox) / scale, + y: (e.clientY - r.top - oy) / scale + }; + } + + function bindKeyboard() { + window.addEventListener('keydown', function (e) { + switch (e.code) { + case 'KeyW': case 'ArrowUp': input.up = true; break; + case 'KeyS': case 'ArrowDown': input.down = true; break; + case 'KeyA': case 'ArrowLeft': input.left = true; break; + case 'KeyD': case 'ArrowRight': input.right = true; break; + case 'Space': input.firing = true; e.preventDefault(); break; + case 'KeyR': US.game.castUlt(); break; + case 'KeyF': { + var on2 = US.game.toggleAutoAim(); + var t3 = document.getElementById('toast'); + if (t3) { + t3.textContent = on2 ? '🎯 Auto-aim ON' : '✋ Auto-aim OFF'; + t3.classList.remove('hidden'); + clearTimeout(t3._tt); + t3._tt = setTimeout(function () { t3.classList.add('hidden'); }, 1400); + } + break; + } + case 'KeyQ': US.game.useBomb(); break; + case 'KeyP': case 'Escape': + if (!togglePause()) { + // if paused via overlay button, resume + if (US.game.resume()) document.getElementById('overlay-pause').classList.add('hidden'); + } + break; + } + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.code) >= 0) e.preventDefault(); + }); + window.addEventListener('keyup', function (e) { + switch (e.code) { + case 'KeyW': case 'ArrowUp': input.up = false; break; + case 'KeyS': case 'ArrowDown': input.down = false; break; + case 'KeyA': case 'ArrowLeft': input.left = false; break; + case 'KeyD': case 'ArrowRight': input.right = false; break; + case 'Space': input.firing = false; break; + } + }); + } + + function togglePause() { + if (US.game.pause()) { + document.getElementById('overlay-pause').classList.remove('hidden'); + return true; + } + return false; + } + + function bindMouse() { + canvas.addEventListener('mousemove', function (e) { + var w = toWorld(e); + input.aimX = w.x; input.aimY = w.y; + }); + canvas.addEventListener('mousedown', function (e) { + if (e.button === 0) { + var w = toWorld(e); + input.aimX = w.x; input.aimY = w.y; + input.firing = true; + } + US.audio.resume(); + }); + window.addEventListener('mouseup', function () { input.firing = false; }); + canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + } + + /* ---------------- touch: twin virtual sticks ---------------- */ + function makeStick(el) { + var knob = el.querySelector('.stick-knob'); + var base = el.getBoundingClientRect(); + var id = null, vec = { x: 0, y: 0 }; + + function setVec(t) { + var r = el.getBoundingClientRect(); + var cx = r.left + r.width / 2, cy = r.top + r.height / 2; + var dx = t.clientX - cx, dy = t.clientY - cy; + var max = r.width / 2; + var d = Math.hypot(dx, dy); + if (d > max) { dx *= max / d; dy *= max / d; } + vec.x = dx / max; vec.y = dy / max; + knob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)'; + } + el.addEventListener('touchstart', function (e) { + e.preventDefault(); + var t = e.changedTouches[0]; + id = t.identifier; + setVec(t); + }, { passive: false }); + el.addEventListener('touchmove', function (e) { + e.preventDefault(); + for (var i = 0; i < e.changedTouches.length; i++) { + if (e.changedTouches[i].identifier === id) setVec(e.changedTouches[i]); + } + }, { passive: false }); + function end(e) { + for (var i = 0; i < e.changedTouches.length; i++) { + if (e.changedTouches[i].identifier === id) { + id = null; + vec.x = 0; vec.y = 0; + knob.style.transform = ''; + } + } + } + el.addEventListener('touchend', end); + el.addEventListener('touchcancel', end); + return vec; + } + + function isTouchDevice() { + return ('ontouchstart' in window) || navigator.maxTouchPoints > 0; + } + + function bindTouch() { + if (!isTouchDevice()) return; + var root = document.getElementById('touch-ui'); + root.classList.remove('hidden'); + touchUI.fireBtn = document.getElementById('tc-fire'); + touchUI.ultBtn = document.getElementById('tc-ult'); + touchUI.bombBtn = document.getElementById('tc-bomb'); + + stickMove = makeStick(document.getElementById('stick-move')); + stickAim = makeStick(document.getElementById('stick-aim')); + + // live vector refs — game reads these directly each frame (no polling) + input.moveVec = stickMove; + input.aimVec = stickAim; + + touchUI.fireBtn.addEventListener('touchstart', function (e) { e.preventDefault(); input.firing = true; }, { passive: false }); + touchUI.fireBtn.addEventListener('touchend', function () { input.firing = false; }); + touchUI.ultBtn.addEventListener('touchstart', function (e) { e.preventDefault(); US.game.castUlt(); }, { passive: false }); + touchUI.bombBtn.addEventListener('touchstart', function (e) { e.preventDefault(); US.game.useBomb(); }, { passive: false }); + } + + /* ---------------- boot ---------------- */ + window.addEventListener('DOMContentLoaded', function () { + canvas = document.getElementById('game'); + US.i18n.setLang(US.save.get().lang); + US.ui.init(); + US.game.mount(canvas); + bindKeyboard(); + bindMouse(); + bindTouch(); + + // default aim ahead of player + input.aimX = 800; input.aimY = 360; + input.moveVec = null; // replaced by live stick ref on touch devices + + // unlock audio on first interaction anywhere + ['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) { + window.addEventListener(ev, function once() { + US.audio.resume(); + window.removeEventListener(ev, once); + }); + }); + }); +})(); diff --git a/js/save.js b/js/save.js new file mode 100644 index 0000000..48ad3df --- /dev/null +++ b/js/save.js @@ -0,0 +1,205 @@ +/* Primal Rampage — profile & persistence v2 (localStorage). + v2 adds: gems (hard currency), permanent upgrades, bestiary kills, + daily quests + login streak, achievements, lifetime stats, pet, + tutorial flag, rewarded-ad daily cap, shake amount + quality setting. + Server-backed leaderboards/accounts plug in later via US.net. */ +(function () { + 'use strict'; + + var KEY = 'neonrampage.profile.v1'; + var US = (window.US = window.US || {}); + + var DEFAULTS = { + name: 'Rookie', + lang: 'en', + coins: 0, + bombs: 1, + weapons: ['pistol'], + heroes: ['recruit'], + skins: ['default'], + weapon: 'pistol', + hero: 'recruit', + skin: 'default', + bestScore: 0, + bestWave: 0, + // --- v2 --- + gems: 0, // hard currency "ngọn lửa" + upgrades: {}, // id -> level + bestiary: {}, // speciesId -> {kills, done} + quests: { date: '', items: [], lastDay: '', streak: 0 }, + achievements: {}, // id -> true + stats: { totalKills: 0, totalAmber: 0, runs: 0 }, + pet: null, // {name, color} once hatched + tut: false, + adDay: { date: '', count: 0 }, // rewarded ads watched today (cap 3) + settings: { + sfx: 0.8, music: 0.5, autofire: true, autoAim: false, onehit: false, + shake: true, shakeAmt: 1, quality: 'high' + }, + board: [] // [{name,score,wave,ts}] + }; + + function clone(o) { return JSON.parse(JSON.stringify(o)); } + + function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); } + + // deep-merge stored data over defaults so new fields appear after updates + function mergeDefaults(dst, def) { + Object.keys(def).forEach(function (k) { + if (!(k in dst)) dst[k] = clone(def[k]); + else if (isPlain(def[k]) && isPlain(dst[k])) mergeDefaults(dst[k], def[k]); + }); + return dst; + } + + var state = null; + + function load() { + try { + var raw = localStorage.getItem(KEY); + state = raw ? mergeDefaults(JSON.parse(raw), DEFAULTS) : clone(DEFAULTS); + state.settings = Object.assign(clone(DEFAULTS.settings), state.settings || {}); + state.stats = Object.assign(clone(DEFAULTS.stats), state.stats || {}); + state.quests = Object.assign(clone(DEFAULTS.quests), state.quests || {}); + state.adDay = Object.assign(clone(DEFAULTS.adDay), state.adDay || {}); + if (!state.weapons.length) state.weapons = ['pistol']; + if (!state.heroes.length) state.heroes = ['recruit']; + if (!state.skins.length) state.skins = ['default']; + } catch (e) { + state = clone(DEFAULTS); + } + return state; + } + + US.save = { + get: function () { return state || load(); }, + save: function () { + try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* private mode */ } + }, + reset: function () { + state = clone(DEFAULTS); + US.save.save(); + }, + + addCoins: function (n) { + state.coins = Math.max(0, (state.coins | 0) + n); + US.save.save(); + }, + + /* ---------- hard currency ---------- */ + addGems: function (n) { + state.gems = Math.max(0, (state.gems | 0) + n); + US.save.save(); + }, + spendGems: function (n) { + if ((state.gems | 0) < n) return false; + state.gems -= n; + US.save.save(); + return true; + }, + + ownWeapon: function (id) { + if (state.weapons.indexOf(id) < 0) { state.weapons.push(id); US.save.save(); } + }, + ownHero: function (id) { + if (state.heroes.indexOf(id) < 0) { state.heroes.push(id); US.save.save(); } + }, + ownSkin: function (id) { + if (state.skins.indexOf(id) < 0) { state.skins.push(id); US.save.save(); } + }, + equip: function (slot, id) { + state[slot] = id; + US.save.save(); + }, + + /* ---------- permanent upgrades ---------- */ + upLevel: function (id) { return state.upgrades[id] | 0; }, + buyUpgrade: function (id, cost) { + var lv = state.upgrades[id] | 0; + if ((state.coins | 0) < cost) return false; + state.coins -= cost; + state.upgrades[id] = lv + 1; + US.save.save(); + return true; + }, + + /* ---------- bestiary ---------- */ + bumpBestiary: function (speciesId, n) { + var e = state.bestiary[speciesId]; + if (!e) e = state.bestiary[speciesId] = { kills: 0, done: false }; + e.kills += (n || 1); + US.save.save(); + return e; + }, + bestiaryDone: function (speciesId, rewardFn) { + var e = state.bestiary[speciesId]; + if (e && !e.done) { + e.done = true; + if (rewardFn) rewardFn(); + US.save.save(); + return true; // newly completed + } + return false; + }, + + /* ---------- lifetime stats ---------- */ + stat: function (k, n) { + if (n === undefined) return state.stats[k] | 0; + state.stats[k] = (state.stats[k] | 0) + n; + US.save.save(); + return state.stats[k]; + }, + + /* ---------- achievements ---------- */ + hasAch: function (id) { return !!state.achievements[id]; }, + unlockAch: function (id) { + if (state.achievements[id]) return false; + state.achievements[id] = true; + US.save.save(); + return true; + }, + + /* ---------- rewarded ads (stub; swap with real SDK later) ---------- */ + ADS_DAILY_CAP: 3, + canWatchAd: function () { + var today = US.save.today(); + if (state.adDay.date !== today) { state.adDay = { date: today, count: 0 }; } + return state.adDay.count < US.save.ADS_DAILY_CAP; + }, + consumeAd: function () { + var today = US.save.today(); + if (state.adDay.date !== today) state.adDay = { date: today, count: 0 }; + state.adDay.count++; + US.save.save(); + }, + + /* ---------- live-ops ---------- */ + isWeekend: function () { + var d = new Date().getDay(); + return d === 0 || d === 6; + }, + + /* ---------- misc ---------- */ + today: function () { + var d = new Date(); + return d.getFullYear() + '-' + + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + + ('0' + d.getDate()).slice(-2); + }, + + submitRun: function (score, wave) { + var isRecord = score > state.bestScore; + if (isRecord) state.bestScore = Math.floor(score); + if (wave > state.bestWave) state.bestWave = wave; + state.board.push({ name: state.name || 'Rookie', score: Math.floor(score), wave: wave, ts: Date.now() }); + state.board.sort(function (a, b) { return b.score - a.score; }); + state.board = state.board.slice(0, 10); + US.save.save(); + // Plug-in point for a real server leaderboard: + // if (US.net && US.net.submitRun) US.net.submitRun(state.board[0]); + return isRecord; + } + }; + + load(); +})(); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..3988e2e --- /dev/null +++ b/js/ui.js @@ -0,0 +1,827 @@ +/* Neon Rampage — DOM UI: menus, shop, settings, leaderboard, overlays. */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + var $ = function (id) { return document.getElementById(id); }; + + var reviveTimer = null; + + function show(el) { el.classList.remove('hidden'); } + function hide(el) { el.classList.add('hidden'); } + + function toast(msg) { + var t = $('toast'); + t.textContent = msg; + show(t); + clearTimeout(toast._h); + toast._h = setTimeout(function () { hide(t); }, 2200); + } + + /* ---------------- screens ---------------- */ + var SCREENS = ['menu', 'shop', 'settings', 'rank', 'howto', 'arena-setup', 'quests', 'bestiary']; + + function L(o) { var sv = US.save.get(); return (o && (o[vi0()] || o.en)) || ''; } + function vi0() { return US.save.get().lang === 'vi' ? 'vi' : 'en'; } + + function showScreen(name) { + SCREENS.forEach(function (s) { + var el = $('screen-' + s); + if (el) (s === name ? show : hide)(el); + }); + $('hud-root').classList.toggle('in-game', !name); + refreshMenuStats(); + } + US.uiShowScreen = showScreen; + + function refreshMenuStats() { + var s = US.save.get(); + $('menu-coins').textContent = '$' + US.util.fmt(s.coins); + if ($('menu-gems')) $('menu-gems').textContent = '🔥 ' + (s.gems | 0); + if ($('dot-upgrades')) { + var canUp = Object.keys(US.UPGRADES || {}).some(function (k) { + var d = US.UPGRADES[k]; + return (s.upgrades[k] | 0) < d.max && s.coins >= upCost(d, s.upgrades[k] | 0); + }); + $('dot-upgrades').classList.toggle('hidden', !canUp); + } + $('menu-best').textContent = + US.i18n.t('menu.bestScore') + ': ' + US.util.fmt(s.bestScore) + + ' · ' + US.i18n.t('menu.bestWave') + ': ' + s.bestWave; + } + + /* ---------------- shop ---------------- */ + var shopTab = 'weapons'; + + /* ---- live-drawn icons: reuse the game's own painters as thumbnails --- */ + function makePreview(w, h, drawFn) { + var wrap = document.createElement('div'); + wrap.className = 'item-prev'; + var cv = document.createElement('canvas'); + cv.width = w; cv.height = h; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + if (drawFn) { + var c2 = cv.getContext('2d'); + try { + drawFn(c2, w, h); + } catch (e) { + // never leave a silent empty plate: show fallback + log the truth + console.warn('[icon] painter failed:', e && e.message, e); + c2.fillStyle = '#ffb938'; + c2.font = '30px system-ui'; + c2.textAlign = 'center'; c2.textBaseline = 'middle'; + c2.fillText('🎨', w / 2, h / 2); + } + } + wrap.appendChild(cv); + return wrap; + } + + var UP_ICON = { hp: '❤️', dmg: '🦷', speed: '💨', ult: '☀️', coin: '🧿', bombs: '💣' }; + + // hero bust: top-down but angled up so it reads like a portrait + function drawHeroIcon(c2, w, h, bodyCol, trimCol, kind) { + c2.save(); + c2.translate(w / 2 - 46 * 1.25, h / 2 - 30 * 1.25); + c2.scale(1.25, 1.25); + US.art.drawHuman(c2, { + x: 46, y: 30, radius: 14, aim: -Math.PI / 2, + walkPhase: 0, moving: false, + color: bodyCol || '#e04b3a', + body: bodyCol || '#e04b3a', trim: trimCol || '#e8dcc4', + weaponKind: kind || 'slingshot', + hurtT: 0, fireT: 0, torch: false, scale: 1.3, seedF: 3 + }, 1); + c2.restore(); + } + + function drawWeaponIcon(c2, w, h, kind) { + c2.save(); + c2.translate(12, h / 2); + c2.scale(1.9, 1.9); + US.art.drawHeldWeapon(c2, kind, 0); + c2.restore(); + } + + var BEST_DRAW = { + grunt: { fn: 'drawRaptor', r: 15 }, + runner: { fn: 'drawRaptor', r: 11 }, + spitter: { fn: 'drawDilo', r: 14 }, + tank: { fn: 'drawTriceratops', r: 21 }, + stego: { fn: 'drawStego', r: 19 }, + pachy: { fn: 'drawPachy', r: 14 }, + splitter: { fn: 'drawPtero', r: 17, opt: false }, + mini: { fn: 'drawPtero', r: 11, opt: true }, + eliteGrunt: { fn: 'drawRaptor', r: 18 }, + bruiser: { fn: 'drawRex', r: 25 }, + artillery: { fn: 'drawSpino', r: 25 }, + hydra: { fn: 'drawGiga', r: 25 } + }; + + function drawBeastIcon(c2, w, h, speciesId, locked) { + var spec = BEST_DRAW[speciesId]; + if (!spec) return; + var def = (US.ENEMIES && (US.ENEMIES[speciesId])) || + (speciesId === 'bruiser' ? (US.BOSSES && US.BOSSES.bruiser) : null) || {}; + var col = def.color || '#5f8f3e'; + var ent = { + x: w / 2, y: h / 2 + 4, radius: spec.r, + face: -Math.PI / 2, color: col, hitFlash: 0, + seed: 3, walkPhase: 0, dashing: 0, dashCd: 9, + shootCd: 1, charging: false, spawnAnim: 0 + }; + c2.save(); + if (locked) c2.globalAlpha = 0.22; + var fn2 = US.art[spec.fn]; + if (spec.opt === undefined) fn2(c2, ent, 1.23); + else fn2(c2, ent, 1.23, spec.opt); + c2.restore(); + if (locked) { + c2.fillStyle = 'rgba(243,231,207,0.75)'; + c2.font = 'bold 26px system-ui'; + c2.textAlign = 'center'; c2.textBaseline = 'middle'; + c2.fillText('?', w / 2, h / 2); + } + } + + function renderShop() { + var s = US.save.get(); + $('shop-coins').textContent = '$' + US.util.fmt(s.coins); + if ($('shop-gems')) $('shop-gems').textContent = '🔥 ' + (s.gems | 0); + ['weapons', 'heroes', 'skins', 'upgrades'].forEach(function (t) { + $('tab-' + t).classList.toggle('active', t === shopTab); + }); + var grid = $('shop-grid'); + grid.innerHTML = ''; + var list, ownArr, equipKey; + + if (shopTab === 'upgrades') { renderUpgrades(grid); return; } + if (shopTab === 'weapons') { list = US.WEAPONS; ownArr = s.weapons; equipKey = 'weapon'; } + else if (shopTab === 'heroes') { list = US.HEROES; ownArr = s.heroes; equipKey = 'hero'; } + else { list = US.SKINS; ownArr = s.skins; equipKey = 'skin'; } + + Object.keys(list).forEach(function (id) { + var it = list[id]; + var owned = ownArr.indexOf(id) >= 0; + var equipped = s[equipKey] === id; + var card = document.createElement('div'); + card.className = 'item' + (equipped ? ' equipped' : ''); + + // live-drawn thumbnail + if (shopTab === 'weapons') { + card.appendChild(makePreview(96, 50, function (c2, w2, h2) { + drawWeaponIcon(c2, w2, h2, it.kind); + })); + } else if (shopTab === 'heroes') { + card.appendChild(makePreview(96, 58, function (c2, w2, h2) { + drawHeroIcon(c2, w2, h2, it.tint || it.body || '#e04b3a', '#e8dcc4', 'slingshot'); + })); + } else if (shopTab === 'skins') { + card.appendChild(makePreview(96, 58, function (c2, w2, h2) { + // bold colour disc so every paint job is unmistakable + c2.fillStyle = it.body || '#e04b3a'; + c2.beginPath(); + c2.arc(w2 / 2, h2 / 2 + 2, 21, 0, Math.PI * 2); + c2.fill(); + c2.strokeStyle = 'rgba(0,0,0,0.4)'; + c2.lineWidth = 2; + c2.stroke(); + drawHeroIcon(c2, w2, h2, it.body, it.trim, (US.WEAPONS[sv.weapon] || {}).kind); + if (it.premium) { + c2.strokeStyle = 'rgba(255,210,74,0.8)'; + c2.lineWidth = 2; + c2.strokeRect(1.5, 1.5, w2 - 3, h2 - 3); + } + // ground truth: sample what actually landed on the canvas + try { + var px3 = c2.getImageData(Math.round(w2 / 2), Math.round(h2 / 2) + 16, 1, 1).data; + console.log('[icon]', it.name, 'want', it.body, + 'got rgb(' + px3[0] + ',' + px3[1] + ',' + px3[2] + ')'); + } catch (e3) { /* tainted ctx: ignore */ } + })); + } else if (shopTab === 'upgrades') { + var up = document.createElement('div'); + up.className = 'item-prev up-icon'; + up.textContent = UP_ICON[it.id] || '💪'; + card.appendChild(up); + } + + var h = document.createElement('div'); + h.className = 'item-name'; + h.textContent = it.premium ? (L(it) || it.name) : it.name; + card.appendChild(h); + + var desc = document.createElement('div'); + desc.className = 'item-desc'; + if (shopTab === 'weapons') { + desc.textContent = 'DMG ' + it.dmg + ' · ROF ' + it.rof + '/s' + + (it.pellets > 1 ? ' · ×' + it.pellets : '') + (it.pierce ? ' · PIERCE' : '') + (it.aoe ? ' · AoE' : ''); + } else if (shopTab === 'heroes') { + desc.textContent = US.i18n.t('hero.statHp') + ' ' + it.hp + ' · ' + + US.i18n.t('hero.statSpeed') + ' ' + it.speed + ' · ' + it.talent; + } else if (it.premium) { + desc.textContent = L(it) || 'Legendary paint'; + card.classList.add('premium'); + } else { + desc.textContent = it.name === 'Default' ? 'Standard issue' : 'Chromatic skin'; + } + card.appendChild(desc); + + var btn = document.createElement('button'); + btn.className = 'btn'; + if (equipped) { + btn.textContent = US.i18n.t('shop.equipped'); + btn.disabled = true; + } else if (owned) { + btn.textContent = US.i18n.t('shop.equip'); + btn.onclick = function () { + US.audio.sfx('ui'); + US.save.equip(equipKey, id); + renderShop(); + }; + } else { + if (it.premium) { + btn.innerHTML = '🔥 ' + it.gems; + btn.onclick = function () { + if (!US.save.spendGems(it.gems)) { toast(L({ en: 'Not enough flames!', vi: 'Không đủ ngọn lửa!' })); return; } + US.save.ownSkin(id); + US.save.equip(equipKey, id); + US.audio.sfx('buy'); + toast(US.i18n.t('shop.bought')); + renderShop(); + }; + } else { + btn.textContent = US.i18n.t('shop.buy') + ' — $' + it.price; + btn.onclick = function () { + var cur = US.save.get(); + if (cur.coins < it.price) { toast(US.i18n.t('toast.notEnough')); return; } + US.save.addCoins(-it.price); + if (shopTab === 'weapons') US.save.ownWeapon(id); + else if (shopTab === 'heroes') US.save.ownHero(id); + else US.save.ownSkin(id); + US.save.equip(equipKey, id); + US.audio.sfx('buy'); + toast(US.i18n.t('shop.bought')); + renderShop(); + }; + } + } + card.appendChild(btn); + grid.appendChild(card); + }); + + // spare bombs row + var bombRow = $('shop-bombs'); + bombRow.innerHTML = ''; + var bl = document.createElement('span'); + bl.className = 'bomb-label'; + bl.textContent = '💣 ' + US.i18n.t('shop.bombs') + ': ' + (s.bombs | 0); + var bb = document.createElement('button'); + bb.className = 'btn'; + bb.textContent = US.i18n.t('shop.buy') + ' — $50'; + bb.onclick = function () { + var cur = US.save.get(); + if (cur.coins < 50) { toast(US.i18n.t('toast.notEnough')); return; } + US.save.addCoins(-50); + cur.bombs = (cur.bombs | 0) + 1; + US.save.save(); + US.audio.sfx('buy'); + renderShop(); + }; + bombRow.appendChild(bl); + bombRow.appendChild(bb); + } + + /* ---------------- settings ---------------- */ + function bindSettings() { + var s = US.save.get(); + var langSel = $('set-lang'); + langSel.value = s.lang; + langSel.onchange = function () { + US.save.get().lang = langSel.value; + US.save.save(); + US.i18n.setLang(langSel.value); + US.i18n.apply(); + }; + $('set-sfx').value = s.settings.sfx * 100; + $('set-music').value = s.settings.music * 100; + $('set-autoaim').checked = !!s.settings.autoAim; + $('set-autofire').checked = !!s.settings.autofire; + $('set-onehit').checked = !!s.settings.onehit; + $('set-shake').checked = !!s.settings.shake; + $('set-name').value = s.name; + + $('set-sfx').oninput = function () { US.save.get().settings.sfx = this.value / 100; US.audio.setVolumes(); US.save.save(); }; + $('set-music').oninput = function () { US.save.get().settings.music = this.value / 100; US.audio.setVolumes(); US.save.save(); }; + if ($('set-autoaim')) $('set-autoaim').onchange = function () { US.save.get().settings.autoAim = this.checked; US.save.save(); }; + $('set-autofire').onchange = function () { US.save.get().settings.autofire = this.checked; US.save.save(); }; + $('set-onehit').onchange = function () { US.save.get().settings.onehit = this.checked; US.save.save(); }; + $('set-shake').onchange = function () { US.save.get().settings.shake = this.checked; US.save.save(); }; + if ($('set-shakeamt')) { + var sa = $('set-shakeamt'); + sa.value = Math.round((US.save.get().settings.shakeAmt == null ? 1 : US.save.get().settings.shakeAmt) * 100); + if ($('val-shakeamt')) $('val-shakeamt').textContent = sa.value + '%'; + sa.oninput = function () { + US.save.get().settings.shakeAmt = this.value / 100; + US.save.save(); + if ($('val-shakeamt')) $('val-shakeamt').textContent = this.value + '%'; + }; + } + if ($('set-quality')) { + $('set-quality').value = US.save.get().settings.quality || 'high'; + $('set-quality').onchange = function () { + US.save.get().settings.quality = this.value; + US.save.save(); + }; + } + $('set-name').onchange = function () { + US.save.get().name = (this.value || 'Rookie').slice(0, 16); + US.save.save(); + toast(US.i18n.t('toast.saved')); + }; + $('set-reset').onclick = function () { + if (confirm(US.i18n.t('set.resetConfirm'))) { + US.save.reset(); + location.reload(); + } + }; + } + + /* ---------------- leaderboard ---------------- */ + function renderRank() { + var s = US.save.get(); + var box = $('rank-body'); + box.innerHTML = ''; + if (!s.board.length) { + var d = document.createElement('div'); + d.className = 'empty'; + d.textContent = US.i18n.t('rank.empty'); + box.appendChild(d); + return; + } + s.board.forEach(function (row, i) { + var r = document.createElement('div'); + r.className = 'rank-row' + (i === 0 ? ' gold' : ''); + [('#' + (i + 1)), row.name, US.util.fmt(row.score), String(row.wave)].forEach(function (cell) { + var c = document.createElement('span'); + c.textContent = cell; + r.appendChild(c); + }); + box.appendChild(r); + }); + } + + /* ---------------- card pick ---------------- */ + function showCardPick(cards, onPick) { + var ov = $('overlay-cards'); + var wrap = $('card-row'); + wrap.innerHTML = ''; + $('card-hint').textContent = US.i18n.t('card.pickHint'); + cards.forEach(function (c) { + var el = document.createElement('div'); + el.className = 'card rarity-' + c.rarity; + var rc = document.createElement('span'); + rc.className = 'card-rarity'; + rc.textContent = US.i18n.t('card.rarity' + c.rarity.charAt(0).toUpperCase() + c.rarity.slice(1)); + var ic = document.createElement('div'); + ic.className = 'card-icon'; + ic.textContent = c.icon; + var tt = document.createElement('div'); + tt.className = 'card-title'; + tt.textContent = c.title; + var dd = document.createElement('div'); + dd.className = 'card-desc'; + dd.textContent = c.desc; + el.appendChild(rc); el.appendChild(ic); el.appendChild(tt); el.appendChild(dd); + el.onclick = function () { + US.audio.sfx('card'); + hide(ov); + onPick(c); + }; + wrap.appendChild(el); + }); + show(ov); + } + + /* ---------------- pause / gameover / revive ---------------- */ + function showPause() { show($('overlay-pause')); } + function hidePause() { hide($('overlay-pause')); } + + function gameOver(stats, mode) { + $('over-title').textContent = US.i18n.t('over.title'); + $('over-record').classList.toggle('hidden', !stats.record); + $('ov-score').textContent = US.util.fmt(stats.score); + $('ov-wave').textContent = mode === 'campaign' ? stats.wave : '—'; + $('ov-kills').textContent = stats.kills; + $('ov-combo').textContent = '×' + stats.bestCombo; + $('ov-coins').textContent = '+$' + stats.coins; + setupAdButton(stats.coins | 0); + show($('overlay-over')); + } + + function arenaOver(winner, g) { + $('over-title').textContent = winner.player ? US.i18n.t('over.win') : 'DEFEAT'; + $('over-record').classList.add('hidden'); + $('ov-score').textContent = g.kills + ' / ' + g.targetKills; + $('ov-wave').textContent = '—'; + $('ov-kills').textContent = g.kills; + $('ov-combo').textContent = '×' + (g.bestCombo || 0); + $('ov-coins').textContent = '+$' + g.runCoins; + show($('overlay-over')); + } + + function showRevive(cost, seconds) { + $('revive-cost').textContent = '-$' + cost; + var left = seconds; + $('revive-count').textContent = left; + show($('overlay-revive')); + clearInterval(reviveTimer); + reviveTimer = setInterval(function () { + left--; + $('revive-count').textContent = Math.max(0, left); + if (left <= 0) US.game.declineRevive(); + }, 1000); + } + function hideRevive() { + clearInterval(reviveTimer); + hide($('overlay-revive')); + } + + + /* ---------------- permanent upgrades tab ---------------- */ + function upCost(def, lv) { return def.base * (lv + 1); } + + function renderUpgrades(grid) { + var sv = US.save.get(); + var bombRow = $('shop-bombs'); + if (bombRow) bombRow.innerHTML = ''; + Object.keys(US.UPGRADES).forEach(function (id) { + var d = US.UPGRADES[id]; + var lv = sv.upgrades[id] | 0; + var maxed = lv >= d.max; + var cost = upCost(d, lv); + + var card = document.createElement('div'); + card.className = 'item upgrade' + (maxed ? ' equipped' : ''); + + var h = document.createElement('div'); + h.className = 'item-name'; + h.textContent = L(d); + card.appendChild(h); + + var pips = document.createElement('div'); + pips.className = 'pips'; + for (var i = 0; i < d.max; i++) { + var pip = document.createElement('span'); + pip.className = 'pip' + (i < lv ? ' on' : ''); + pips.appendChild(pip); + } + card.appendChild(pips); + + var desc = document.createElement('div'); + desc.className = 'item-desc'; + desc.textContent = L({ en: d.en2 || d.per, vi: d.per }); + card.appendChild(desc); + + var btn = document.createElement('button'); + btn.className = 'btn'; + if (maxed) { + btn.textContent = US.i18n.t('shop.equipped'); + btn.disabled = true; + } else { + btn.textContent = US.i18n.t('shop.buy') + ' — $' + US.util.fmt(cost); + btn.onclick = function () { + if (US.save.get().coins < cost) { toast(US.i18n.t('toast.notEnough')); return; } + US.save.buyUpgrade(id, cost); + US.audio.sfx('buy'); + toast('💪 ' + L(d) + ' Lv.' + ((sv.upgrades[id] | 0))); + renderShop(); + refreshMenuStats(); + }; + } + card.appendChild(btn); + grid.appendChild(card); + }); + } + + /* ---------------- daily quests + achievements ---------------- */ + function questBadge() { + var sv = US.save.get(); + var claimable = (sv.quests.items || []).some(function (q) { + return q.done && !q.claimed; + }); + if ($('dot-quests')) $('dot-quests').classList.toggle('hidden', !claimable); + } + + function renderQuests() { + var sv = US.save.get(); + ensureUiQuests(sv); + $('quest-streak').textContent = '🔥 ' + L({ + en: 'Login streak: day ' + (sv.quests.streak | 0), + vi: 'Điểm danh ngày thứ ' + (sv.quests.streak | 0) + }) + (sv.gems ? ' · 🔥 ×' + sv.gems : ''); + $('h3-ach').textContent = '🏆 ' + L({ en: 'Achievements', vi: 'Thành tựu' }); + + var list = $('quest-list'); + list.innerHTML = ''; + sv.quests.items.forEach(function (q) { + var def = null; + US.QUEST_POOL.forEach(function (d0) { if (d0.id === q.defId) def = d0; }); + if (!def) return; + + var row = document.createElement('div'); + row.className = 'item quest-row' + (q.claimed ? ' equipped' : ''); + var t = document.createElement('div'); + t.className = 'item-name'; + t.textContent = L(def.text); + row.appendChild(t); + + var bar = document.createElement('div'); + bar.className = 'qbar'; + var fill = document.createElement('div'); + fill.className = 'qfill'; + fill.style.width = Math.min(100, (q.prog / q.goal) * 100) + '%'; + bar.appendChild(fill); + row.appendChild(bar); + + var meta = document.createElement('div'); + meta.className = 'item-desc'; + meta.textContent = Math.min(q.prog, q.goal) + ' / ' + q.goal + + ' · 🔥 +' + def.gem + ' $+' + def.coins; + row.appendChild(meta); + + var btn = document.createElement('button'); + btn.className = 'btn'; + if (q.claimed) { btn.textContent = '✓'; btn.disabled = true; } + else if (q.done) { + btn.textContent = L({ en: 'Claim', vi: 'Nhận thưởng' }); + btn.onclick = function () { + q.claimed = true; + US.save.addGems(def.gem); + US.save.addCoins(def.coins); + US.audio.sfx('buy'); + toast('📜 +🔥' + def.gem + ' +$' + def.coins); + US.save.save(); + renderQuests(); + refreshMenuStats(); + }; + } else { + btn.textContent = '…'; + btn.disabled = true; + } + row.appendChild(btn); + list.appendChild(row); + }); + + // achievements + var al = $('ach-list'); + al.innerHTML = ''; + Object.keys(US.ACHIEVEMENTS).forEach(function (id) { + var d = US.ACHIEVEMENTS[id]; + var got = US.save.hasAch(id); + var el = document.createElement('div'); + el.className = 'ach-row' + (got ? ' done' : ''); + el.textContent = (got ? '🏆 ' : '🔒 ') + L(d.text) + ' (🔥' + d.gem + ')'; + al.appendChild(el); + }); + } + + function ensureUiQuests(sv) { + // game.js owns the generator; nudge it via a run-less call + if (!sv.quests.items || sv.quests.items.length !== 3 || sv.quests.date !== US.save.today()) { + // replicate light version: full logic lives in game.js ensureQuests + if (US.game && US.game.ensureQuests) US.game.ensureQuests(); + else { + sv.quests.items = sv.quests.items || []; + } + } + } + + /* ---------------- bestiary ---------------- */ + function renderBestiary() { + var sv = US.save.get(); + var grid = $('bestiary-grid'); + grid.innerHTML = ''; + var keys = Object.keys(US.BESTIARY); + var doneN = 0; + keys.forEach(function (id) { + var d = US.BESTIARY[id]; + var e = sv.bestiary[id] || { kills: 0, done: false }; + if (e.done) doneN++; + + var card = document.createElement('div'); + card.className = 'item best-card' + (e.done ? ' equipped' : (e.kills > 0 ? '' : ' locked')); + + var h = document.createElement('div'); + h.className = 'item-name'; + h.textContent = e.kills > 0 ? L(d.name) : '? ? ?'; + card.appendChild(h); + + card.appendChild(makePreview(104, 60, function (c2, w2, h2) { + drawBeastIcon(c2, w2, h2, id, e.kills <= 0); + if (e.done) { + c2.strokeStyle = 'rgba(255,210,74,0.85)'; + c2.lineWidth = 2; + c2.strokeRect(1.5, 1.5, w2 - 3, h2 - 3); + } + })); + + var lore = document.createElement('div'); + lore.className = 'item-desc'; + lore.textContent = e.done ? L(d.lore) + : L({ en: 'Slay ' + d.goal + ' to reveal', vi: 'Hạ ' + d.goal + ' con để mở ký sự' }) + + ' (' + Math.min(e.kills, d.goal) + '/' + d.goal + ')'; + card.appendChild(lore); + + var rw = document.createElement('div'); + rw.className = 'item-desc'; + rw.style.color = '#ffb938'; + rw.textContent = e.done ? '✓ 🔥' + d.gem : '🔥' + d.gem; + card.appendChild(rw); + + grid.appendChild(card); + }); + $('best-progress').textContent = '📖 ' + doneN + ' / ' + keys.length; + } + + /* ---------------- rewarded ad stub (plug real SDK here) ---------------- */ + function setupAdButton(runCoins) { + var btn = $('btn-adx2'); + if (!btn) return; + if (!US.save.canWatchAd()) { btn.classList.add('hidden'); return; } + btn.classList.remove('hidden'); + btn.textContent = L({ + en: '▶ Watch ad — bonus +50% amber (' + runCoins + ' → ' + Math.floor(runCoins * 1.5) + ')', + vi: '▶ Xem quảng cáo — +50% hổ phách (' + runCoins + ' → ' + Math.floor(runCoins * 1.5) + ')' + }); + btn.onclick = function () { + btn.disabled = true; + var left = 5; + btn.textContent = L({ en: 'Ad playing… ', vi: 'Đang phát quảng cáo… ' }) + left + 's'; + var iv = setInterval(function () { + left--; + if (left > 0) { btn.textContent = L({ en: 'Ad playing… ', vi: 'Đang phát quảng cáo… ' }) + left + 's'; return; } + clearInterval(iv); + US.save.consumeAd(); + var bonus = Math.floor(runCoins * 0.5); + US.save.addCoins(bonus); + btn.classList.add('hidden'); + toast('💰 +' + bonus); + US.audio.sfx('buy'); + }, 1000); + }; + } + + /* ---------------- first-run tutorial ---------------- */ + var TUT_STEPS = [ + { en: 'Move with WASD / arrows (or the touch sticks).', vi: 'Di chuyển bằng WASD / mũi tên (hoặc joystick cảm ứng).' }, + { en: 'You auto-aim and auto-fire at the nearest beast.', vi: 'Bạn tự ngắm và tự bắn vào con thú gần nhất.' }, + { en: 'Kill fast to build COMBO and go ON FIRE — double damage!', vi: 'Giết nhanh để lên COMBO và BÙNG CHÁY — sát thương x2!' }, + { en: 'Grab the amber ($) they drop — spend it on weapons & permanent power.', vi: 'Nhặt hổ phách ($) rơi ra — dùng mua vũ khí & nâng cấp vĩnh viễn.' }, + { en: 'Every 5th wave a BOSS stalks in. Feel the footsteps. Good luck, chief.', vi: 'Mỗi wave thứ 5 trùm sẽ xuất hiện. Nghe tiếng bước chân đi. Chúc may mắn, thủ lĩnh.' } + ]; + var tutStep = -1; + + function startTutorial() { + tutStep = 0; + showTutStep(); + show($('overlay-tutorial')); + } + function showTutStep() { + $('tut-title').textContent = L({ en: 'Welcome to the Valley', vi: 'Chào mừng đến Thung Lũng' }); + $('tut-step').textContent = L(TUT_STEPS[tutStep]); + $('btn-tut-next').textContent = tutStep >= TUT_STEPS.length - 1 + ? L({ en: 'Let’s hunt!', vi: 'Đi săn thôi!' }) + : L({ en: 'Next', vi: 'Tiếp' }); + } + function endTutorial() { + hide($('overlay-tutorial')); + US.save.get().tut = true; + US.save.save(); + tutStep = -1; + } + + /* ---------------- wiring ---------------- */ + function init() { + US.i18n.setLang(US.save.get().lang); + US.i18n.apply(); + + $('btn-campaign').onclick = function () { US.audio.resume(); US.audio.sfx('ui'); US.game.start('campaign'); showScreen(null); }; + $('btn-arena').onclick = function () { US.audio.sfx('ui'); showScreen('arena-setup'); }; + $('btn-shop').onclick = function () { US.audio.sfx('ui'); renderShop(); showScreen('shop'); }; + $('btn-settings').onclick = function () { US.audio.sfx('ui'); bindSettings(); showScreen('settings'); }; + $('btn-rank').onclick = function () { US.audio.sfx('ui'); renderRank(); showScreen('rank'); }; + $('btn-howto').onclick = function () { US.audio.sfx('ui'); showScreen('howto'); }; + // commercialization screens — guarded so a stale HTML shell can never + // crash menu init (feature buttons simply stay hidden) + // subtle hover tick on the pro menu list + document.querySelectorAll('.btn-menu').forEach(function (b) { + b.addEventListener('mouseenter', function () { + if (US.audio && US.audio.hoverTick) US.audio.hoverTick(); + }); + }); + + if ($('btn-quests')) $('btn-quests').onclick = function () { US.audio.sfx('ui'); renderQuests(); showScreen('quests'); }; + if ($('btn-bestiary')) $('btn-bestiary').onclick = function () { US.audio.sfx('ui'); renderBestiary(); showScreen('bestiary'); }; + if ($('btn-upgrades')) $('btn-upgrades').onclick = function () { US.audio.sfx('ui'); shopTab = 'upgrades'; renderShop(); showScreen('shop'); }; + + function setTxt(id, v) { if ($(id)) $(id).textContent = v; } + setTxt('lbl-quests', L({ en: 'Quests', vi: 'Nhiệm vụ' })); + setTxt('lbl-bestiary', L({ en: 'Bestiary', vi: 'Ký sự thú' })); + setTxt('lbl-upgrades', L({ en: 'Upgrades', vi: 'Nâng cấp' })); + setTxt('lbl-tabup', L({ en: 'Upgrades', vi: 'Nâng cấp' })); + setTxt('h2-quests', '📜 ' + L({ en: 'Daily Quests', vi: 'Nhiệm vụ mỗi ngày' })); + setTxt('h2-best', '📖 ' + L({ en: 'Bestiary of the Valley', vi: 'Ký sự thú thung lũng' })); + setTxt('lbl-shakeamt', L({ en: 'Screen shake', vi: 'Rung màn hình' })); + setTxt('lbl-quality', L({ en: 'Effects quality', vi: 'Chất lượng hiệu ứng' })); + + document.querySelectorAll('[data-back]').forEach(function (b) { + b.onclick = function () { US.audio.sfx('ui'); showScreen('menu'); }; + }); + + [['tab-weapons', 'weapons'], ['tab-heroes', 'heroes'], ['tab-skins', 'skins'], ['tab-upgrades', 'upgrades']].forEach(function (p) { + $(p[0]).onclick = function () { US.audio.sfx('ui'); shopTab = p[1]; renderShop(); }; + }); + + // arena setup + $('arena-bots').onchange = function () {}; + $('arena-target').onchange = function () {}; + $('arena-start').onclick = function () { + US.audio.resume(); + US.audio.sfx('ui'); + US.game.start('arena', { + arena: { + bots: parseInt($('arena-bots').value, 10), + target: parseInt($('arena-target').value, 10) + } + }); + showScreen(null); + }; + + // first-run tutorial (guarded for stale shells) + if ($('overlay-tutorial') && !$('overlay-tutorial').classList.contains('hidden')) hide($('overlay-tutorial')); + if (!$('btn-tut-next')) return; + $('btn-tut-next').onclick = function () { + US.audio.sfx('ui'); + if (tutStep >= TUT_STEPS.length - 1) endTutorial(); + else { tutStep++; showTutStep(); } + }; + $('btn-tut-skip').onclick = function () { US.audio.sfx('ui'); endTutorial(); }; + $('btn-tut-skip').textContent = L({ en: 'Skip', vi: 'Bỏ qua' }); + if (!US.save.get().tut && !$('btn-retry').onclick) setTimeout(startTutorial, 400); + + // pause + $('btn-pause').onclick = function () { + if (US.game.pause()) { US.audio.sfx('ui'); showPause(); } + }; + $('btn-resume').onclick = function () { + if (US.game.resume()) { US.audio.sfx('ui'); hidePause(); } + }; + $('btn-quit').onclick = function () { + // bank the run politely before leaving + var g = US.game.get(); + if (g && !g.over && g.mode === 'campaign' && (g.wave > 0)) { + US.save.addCoins(g.runCoins); + US.save.submitRun(g.score, g.wave || 0); + } + US.game.stop(); + US.ui.hideRevive(); + hidePause(); + hide($('overlay-over')); + hide($('overlay-cards')); + showScreen('menu'); + }; + + // game over + $('btn-retry').onclick = function () { + var g = US.game.get(); + var mode = g ? g.mode : 'campaign'; + US.game.stop(); + hide($('overlay-over')); + US.audio.sfx('ui'); + if (mode === 'arena') showScreen('arena-setup'); + else { US.game.start('campaign'); showScreen(null); } + }; + $('btn-tomenu').onclick = function () { + US.game.stop(); + hide($('overlay-over')); + showScreen('menu'); + }; + + // revive + $('btn-revive').onclick = function () { US.audio.sfx('buy'); US.game.acceptRevive(); }; + $('btn-nodecline').onclick = function () { US.game.declineRevive(); }; + + showScreen('menu'); + } + + US.ui = { + init: init, + showCardPick: showCardPick, + gameOver: gameOver, + arenaOver: arenaOver, + showRevive: showRevive, + hideRevive: hideRevive, + toast: toast, + questBadge: questBadge + }; +})(); diff --git a/js/util.js b/js/util.js new file mode 100644 index 0000000..f97fcb8 --- /dev/null +++ b/js/util.js @@ -0,0 +1,13 @@ +/* Neon Rampage — small shared utilities. */ +(function () { + 'use strict'; + var US = (window.US = window.US || {}); + US.util = { + rand: function (a, b) { return a + Math.random() * (b - a); }, + randInt: function (a, b) { return Math.floor(a + Math.random() * (b - a + 1)); }, + pick: function (arr) { return arr[Math.floor(Math.random() * arr.length)]; }, + clamp: function (v, a, b) { return v < a ? a : v > b ? b : v; }, + dist2: function (ax, ay, bx, by) { var dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }, + fmt: function (n) { return String(Math.floor(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } + }; +})(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..d5624a1 --- /dev/null +++ b/style.css @@ -0,0 +1,466 @@ +/* PRIMAL RAMPAGE — stone-age jungle theme. + Palette: packed dirt, bone parchment, ember fire, moss green, blood red. */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; height: 100%; + overflow: hidden; + background: #191207; + color: #f3e7cf; + font-family: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +#game { + position: fixed; inset: 0; + width: 100%; height: 100%; + display: block; +} + +#ui-root { position: fixed; inset: 0; pointer-events: none; } +#ui-root section, .overlay, .icon-btn, #touch-ui, #toast { pointer-events: auto; } + +.hidden { display: none !important; } + +/* ---------------- screens ---------------- */ +section.screen { + position: absolute; inset: 0; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 18px; + background: + radial-gradient(900px 480px at 50% -12%, rgba(255,138,61,0.06), transparent 62%), + linear-gradient(rgba(20,13,7,0.44), rgba(10,6,3,0.52)); + backdrop-filter: blur(1px); + overflow-y: auto; + padding: 32px 16px; +} +section.screen.narrow { justify-content: flex-start; padding-top: 8vh; } + +h2 { + font-size: clamp(22px, 4vw, 32px); + letter-spacing: 4px; + text-transform: uppercase; + color: #f3e7cf; + text-shadow: 0 2px 0 #3a2a16, 0 4px 14px rgba(0,0,0,0.6); +} + +.logo h1 { + font-size: clamp(44px, 9vw, 88px); + letter-spacing: 5px; + color: #f3e7cf; + text-shadow: + 0 1px 0 #8a6234, + 0 3px 0 #55381c, + 0 6px 18px rgba(0,0,0,0.75); +} +.logo h1 span { color: #ff8a3d; text-shadow: 0 1px 0 #7e3d12, 0 3px 0 #55230c, 0 6px 20px rgba(224,75,58,0.45); margin-left: 14px; } +.logo p { opacity: 0.78; margin-top: 8px; font-size: 14px; text-align: center; font-style: italic; } + +.menu-stats { display: flex; flex-direction: column; gap: 4px; align-items: center; opacity: 0.9; font-size: 14px; } +.coins { color: #ffb938; font-weight: 700; font-size: 17px; } + +.menu-nav { display: flex; flex-direction: column; gap: 12px; width: min(340px, 86vw); } + +/* ---------------- buttons: carved wood & bone ---------------- */ +.btn { + pointer-events: auto; + appearance: none; + border: 1px solid #5c4423; + border-bottom-width: 3px; + background: linear-gradient(#33261502, rgba(51,38,21,0.01)), #2b1f11; + background-color: #2b1f11; + color: #f3e7cf; + font: 700 14px/1 'Trebuchet MS', system-ui, sans-serif; + letter-spacing: 1.6px; + text-transform: uppercase; + padding: 12px 18px; + border-radius: 9px; + cursor: pointer; + transition: transform .08s ease, box-shadow .15s ease, border-color .15s, background .15s; +} +.btn:hover:not(:disabled) { + transform: translateY(-1px); + border-color: #8a6234; + background-color: #38290f; + box-shadow: 0 4px 16px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,185,56,0.12); +} +.btn:active:not(:disabled) { transform: translateY(1px); border-bottom-width: 1px; } +.btn:disabled { opacity: 0.45; cursor: default; } +.btn.big { font-size: 17px; padding: 15px 22px; } +.btn.primary { + border-color: #93400f; + color: #ffe9cf; + background: linear-gradient(180deg, rgba(224,75,58,0.28), rgba(224,75,58,0.08)), #33180e; +} +.btn.primary:hover:not(:disabled) { border-color: #ff8a3d; box-shadow: 0 4px 18px rgba(224,75,58,0.35); } +.btn.danger { border-color: #6e2a1c; color: #ffb9ab; } +.btn.tab.active { border-color: #ffb938; color: #ffe9b8; background-color: #3a2a10; } + +.form-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } + +/* ---------------- forms ---------------- */ +.form-row { + width: min(420px, 88vw); + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px 4px; + border-bottom: 1px solid rgba(232,220,196,0.09); + font-size: 15px; +} +.form-row input[type="range"] { width: 180px; accent-color: #ff8a3d; } +.form-row input[type="checkbox"] { width: 20px; height: 20px; accent-color: #e04b3a; } +.form-row input[type="text"], .form-row select { + background: #241a0e; color: #f3e7cf; + border: 1px solid #5c4423; + border-radius: 8px; padding: 8px 10px; font-size: 14px; max-width: 200px; +} + +/* ---------------- shop ---------------- */ +.screen-topline { display: flex; gap: 14px; align-items: center; } +.tabs { display: flex; gap: 8px; } +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + width: min(960px, 94vw); +} +.item { + border: 1px solid rgba(140,105,60,0.4); + background: linear-gradient(rgba(60,44,24,0.35), rgba(36,26,14,0.85)); + border-radius: 14px; + padding: 14px; + display: flex; flex-direction: column; gap: 10px; + box-shadow: inset 0 1px 0 rgba(232,220,196,0.08); +} +.item.equipped { border-color: #ffb938; box-shadow: 0 0 16px rgba(255,185,56,0.15), inset 0 1px 0 rgba(232,220,196,0.08); } +.item-name { font-weight: 800; letter-spacing: 1px; font-size: 16px; } +.swatch { height: 44px; border-radius: 10px; opacity: 0.95; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.35); } +.item-desc { font-size: 12.5px; opacity: 0.75; min-height: 32px; } +.bomb-row { display: flex; align-items: center; gap: 14px; } +.bomb-label { font-weight: 700; color: #ff8a3d; } + +/* ---------------- leaderboard ---------------- */ +.rank-row { + width: min(520px, 90vw); + display: grid; grid-template-columns: 46px 1fr 110px 70px; + padding: 9px 12px; border-radius: 8px; + font-size: 14px; +} +.rank-head { font-weight: 800; color: #ffb938; letter-spacing: 1px; } +#rank-body { display: flex; flex-direction: column; gap: 4px; } +#rank-body .rank-row { background: rgba(43,31,17,0.8); } +#rank-body .gold { border: 1px solid rgba(255,185,56,0.5); } +.empty { opacity: 0.55; padding: 18px; font-style: italic; } + +.how-list { list-style: none; display: flex; flex-direction: column; gap: 10px; width: min(560px, 90vw); font-size: 14.5px; line-height: 1.5; opacity: 0.92; } +.how-list li::before { content: '🦴 '; filter: sepia(0.4); } + +/* ---------------- HUD ---------------- */ +#hud-root.in-game { pointer-events: none; } +.icon-btn { + position: absolute; top: calc(10px + env(safe-area-inset-top)); left: 12px; + width: 42px; height: 42px; border-radius: 10px; + background: rgba(43,31,17,0.82); color: #e8dcc4; + border: 1px solid #5c4423; + font-size: 18px; cursor: pointer; pointer-events: auto; +} +#hud-root:not(.in-game) .icon-btn { display: none; } + +/* ---------------- touch controls ---------------- */ +#touch-ui { position: absolute; inset: 0; } +.stick { + position: absolute; bottom: 26px; width: 120px; height: 120px; + border-radius: 50%; + border: 2px solid rgba(232,220,196,0.28); + background: rgba(36,26,14,0.4); +} +#stick-move { left: 26px; } +#stick-aim { right: 26px; } +.stick-knob { + position: absolute; left: 50%; top: 50%; + width: 52px; height: 52px; margin: -26px 0 0 -26px; + border-radius: 50%; + background: rgba(160,120,66,0.5); + border: 2px solid rgba(232,220,196,0.55); +} +#touch-buttons { position: absolute; right: 170px; bottom: 40px; display: flex; gap: 12px; align-items: flex-end; } +.tc-btn { + width: 74px; height: 74px; border-radius: 50%; + font-size: 26px; color: #fff; + background: rgba(224,75,58,0.3); + border: 2px solid rgba(224,75,58,0.65); +} +.tc-btn.small { width: 56px; height: 56px; font-size: 20px; background: rgba(36,26,14,0.65); border-color: rgba(140,105,60,0.5); } + +/* ---------------- overlays ---------------- */ +.overlay { + position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 20px; + background: rgba(15,10,4,0.86); + backdrop-filter: blur(3px); + z-index: 10; + padding: 24px; +} +.overlay h2 { text-shadow: 0 2px 0 #3a2a16, 0 6px 22px rgba(224,75,58,0.35); } +.hint { opacity: 0.68; font-size: 13.5px; text-align: center; } +.col { display: flex; flex-direction: column; gap: 12px; width: min(300px, 84vw); } + +.stats-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; width: min(560px, 92vw); +} +.stats-grid > div { + background: rgba(43,31,17,0.88); + border: 1px solid rgba(140,105,60,0.35); + border-radius: 12px; + padding: 12px; text-align: center; +} +.stats-grid label { display: block; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; opacity: 0.6; margin-bottom: 6px; } +.stats-grid b { font-size: 19px; color: #ffb938; } + +.record { + color: #ffb938; font-weight: 800; letter-spacing: 3px; + animation: pulse 1s infinite alternate; +} +@keyframes pulse { from { text-shadow: 0 0 8px rgba(255,185,56,.4);} to { text-shadow: 0 0 26px rgba(255,138,61,.95);} } + +/* ---------------- cards: carved bone talismans ---------------- */ +#card-row { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; } +.card { + width: 190px; min-height: 240px; + border-radius: 16px; + background: + radial-gradient(120px 70px at 50% 0%, rgba(255,220,170,0.10), transparent 70%), + linear-gradient(180deg, #3a2b16, #221708); + border: 2px solid var(--rc, #9c8a72); + box-shadow: 0 6px 22px -6px rgba(0,0,0,0.8), inset 0 0 24px -8px var(--rc, transparent); + display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 18px 14px; + cursor: pointer; + transition: transform .12s ease, box-shadow .12s ease; +} +.card:hover { transform: translateY(-6px) scale(1.03); box-shadow: 0 12px 26px -8px rgba(0,0,0,0.9), inset 0 0 30px -6px var(--rc); } +.rarity-common { --rc: #9c8a72; } +.rarity-rare { --rc: #58a55c; } +.rarity-epic { --rc: #ff8a3d; } +.card-rarity { font-size: 10px; letter-spacing: 2px; color: var(--rc); font-weight: 800; } +.card-icon { font-size: 42px; filter: drop-shadow(0 3px 6px rgba(0,0,0,0.6)); } +.card-title { font-weight: 800; font-size: 16px; text-align: center; color: #f3e7cf; } +.card-desc { font-size: 13px; opacity: 0.78; text-align: center; } + +/* ---------------- toast ---------------- */ +#toast { + position: absolute; bottom: 9vh; left: 50%; transform: translateX(-50%); + background: rgba(43,31,17,0.94); + border: 1px solid rgba(255,185,56,0.45); + color: #ffe9b8; + padding: 10px 20px; border-radius: 999px; + font-size: 14px; font-weight: 700; z-index: 20; +} + + +/* ---- commercialization UI bits ---- */ +.badge-dot { + display: inline-block; + width: 9px; height: 9px; + border-radius: 50%; + background: #ff8a3d; + box-shadow: 0 0 8px #ff8a3d; + margin-left: 6px; + vertical-align: middle; +} +.badge-dot.hidden { display: none; } +.gems { color: #ffd24a; font-weight: bold; } + +.pips { display: flex; gap: 5px; margin: 8px 0; } +.pip { + width: 22px; height: 8px; + border-radius: 4px; + background: #2a1c10; + border: 1px solid #4a3620; +} +.pip.on { background: linear-gradient(90deg, #e8a33d, #ffb938); box-shadow: 0 0 6px #ffb93866; } + +.item.premium .item-name { color: #ffd24a; } +.item.premium { border-color: #ffb93888; } +.item.locked .swatch { filter: grayscale(1) brightness(0.5); } +.item.locked .item-name { color: rgba(232,220,196,0.45); } + +.col { display: flex; flex-direction: column; gap: 12px; } +.quest-row { display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: center; } +.qbar { + height: 10px; + border-radius: 5px; + background: #2a1c10; + overflow: hidden; + border: 1px solid #4a3620; +} +.qfill { + height: 100%; + border-radius: 5px; + background: linear-gradient(90deg, #58a55c, #8ac74a); + transition: width 0.3s ease; +} +.ach-row { + padding: 9px 12px; + border-radius: 9px; + background: rgba(20,13,7,0.65); + border: 1px solid #3a2a16; + font-size: 13.5px; +} +.ach-row.done { border-color: #ffd24a66; color: #ffe4bd; } + +.btn.gem-btn { + border-color: #ffb938; + color: #ffd24a; + margin-top: 8px; +} + +/* ===================== PRO MAIN MENU ===================== */ +#screen-menu { + justify-content: stretch; + align-items: stretch; + padding: 0; +} +.menu-wrap { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 6vh 0 12vh 7vw; + max-width: 460px; +} +#screen-menu .logo { text-align: left; margin-bottom: 4px; } +#screen-menu .logo h1 { + font-size: clamp(40px, 5.2vw, 64px); + line-height: 0.95; + letter-spacing: 3px; +} +.bestline { + color: rgba(243,231,207,0.85); + font-size: 13.5px; + letter-spacing: 0.4px; + margin: 10px 0 26px 4px; + text-shadow: 0 1px 2px rgba(0,0,0,0.7); +} + +.menu-nav.pro { + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; +} +.btn-menu { + position: relative; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 13px 18px 13px 20px; + font: 700 17px/1 system-ui, sans-serif; + letter-spacing: 1.6px; + text-transform: uppercase; + color: #f3e7cf; + text-align: left; + background: linear-gradient(90deg, rgba(16,10,5,0.62), rgba(16,10,5,0.18)); + border: none; + border-left: 3px solid rgba(255,170,90,0.35); + cursor: pointer; + transition: transform 0.14s ease, background 0.14s ease, border-color 0.14s ease; + overflow: hidden; +} +.btn-menu.sm { font-size: 13px; padding: 9px 16px 9px 20px; letter-spacing: 1px; opacity: 0.92; } +.btn-menu .chev { + margin-left: auto; + opacity: 0; + transform: translateX(-8px); + transition: all 0.15s ease; + color: #ffb938; + font-size: 20px; +} +.btn-menu:hover, .btn-menu:focus-visible { + transform: translateX(10px); + border-left-color: #ffb938; + background: linear-gradient(90deg, rgba(60,32,12,0.85), rgba(60,32,12,0.22)); + outline: none; +} +.btn-menu:hover .chev, .btn-menu:focus-visible .chev { opacity: 1; transform: translateX(0); } +.btn-menu.primary { + font-size: 24px; + padding: 19px 22px; + color: #fff6e6; + border-left-color: #ff8a3d; + background: linear-gradient(90deg, rgba(196,74,20,0.92), rgba(120,45,12,0.55)); + box-shadow: 0 6px 26px rgba(255,110,30,0.28); + margin-bottom: 8px; +} +.btn-menu.primary:hover { + transform: translateX(12px) scale(1.01); + box-shadow: 0 8px 34px rgba(255,120,35,0.42); + background: linear-gradient(90deg, rgba(224,90,25,0.95), rgba(140,52,14,0.6)); +} +.menu-sep { + height: 1px; + margin: 7px 4px; + background: linear-gradient(90deg, rgba(255,170,90,0.4), transparent); +} + +/* staggered entrance */ +@keyframes menuIn { + from { opacity: 0; transform: translateX(-26px); } + to { opacity: 1; transform: none; } +} +.btn-menu { animation: menuIn 0.38s ease backwards; } +.btn-menu:nth-child(1) { animation-delay: 0.03s; } +.btn-menu:nth-child(2) { animation-delay: 0.08s; } +.btn-menu:nth-child(3) { animation-delay: 0.13s; } +.btn-menu:nth-child(4) { animation-delay: 0.17s; } +.btn-menu:nth-child(5) { animation-delay: 0.21s; } +.btn-menu:nth-child(6) { animation-delay: 0.25s; } +.btn-menu:nth-child(7) { animation-delay: 0.29s; } +.btn-menu:nth-child(8) { animation-delay: 0.33s; } +.btn-menu:nth-child(9) { animation-delay: 0.37s; } +.btn-menu:nth-child(10){ animation-delay: 0.41s; } +.menu-wrap > * { animation: menuIn 0.45s ease backwards; } + +.menu-bottombar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 22px; + background: linear-gradient(rgba(8,5,2,0.0), rgba(8,5,2,0.72)); + font-size: 12px; + color: rgba(232,220,196,0.75); + letter-spacing: 0.6px; +} +.menu-bottombar .ver { text-transform: uppercase; } +.menu-bottombar .wallet { display: flex; gap: 18px; font-weight: bold; } +.menu-bottombar .coins { color: #ffb938; font-size: 14px; } +.menu-bottombar .gems { color: #ffd24a; font-size: 14px; } +@media (max-width: 720px) { + .menu-wrap { padding: 4vh 0 10vh 5vw; max-width: 86vw; } + .btn-menu { font-size: 15px; } + .btn-menu.primary { font-size: 19px; } +} + +/* live-drawn thumbnails in shop & bestiary */ +.item-prev { + height: 62px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + margin-bottom: 9px; + background: + radial-gradient(circle at 50% 38%, rgba(255,190,120,0.16), transparent 68%), + linear-gradient(rgba(10,7,4,0.55), rgba(10,7,4,0.3)); + box-shadow: inset 0 0 0 1px rgba(255,196,130,0.18); + overflow: hidden; +} +.item-prev canvas { display: block; } +.item-prev.up-icon { font-size: 36px; }