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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.dsh-notes.json
|
||||||
|
*.log
|
||||||
@@ -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).
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#191207" />
|
||||||
|
<title>Primal Rampage — Stone Age Survival</title>
|
||||||
|
<link rel="stylesheet" href="./style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="game"></canvas>
|
||||||
|
|
||||||
|
<!-- ===================== DOM UI ===================== -->
|
||||||
|
<div id="ui-root">
|
||||||
|
|
||||||
|
<!-- MENU -->
|
||||||
|
<section id="screen-menu" class="screen">
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>PRIMAL<span>RAMPAGE</span></h1>
|
||||||
|
<p data-i18n="menu.subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<div id="menu-best" class="bestline"></div>
|
||||||
|
|
||||||
|
<nav class="menu-nav pro">
|
||||||
|
<button id="btn-campaign" class="btn-menu primary" data-i18n="menu.campaign"></button>
|
||||||
|
<button id="btn-arena" class="btn-menu" data-i18n="menu.arena"></button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button id="btn-upgrades" class="btn-menu">💪 <span id="lbl-upgrades"></span><span class="badge-dot hidden" id="dot-upgrades"></span><span class="chev">▸</span></button>
|
||||||
|
<button id="btn-quests" class="btn-menu">📜 <span id="lbl-quests"></span><span class="badge-dot hidden" id="dot-quests"></span><span class="chev">▸</span></button>
|
||||||
|
<button id="btn-bestiary" class="btn-menu">📖 <span id="lbl-bestiary"></span><span class="chev">▸</span></button>
|
||||||
|
<button id="btn-shop" class="btn-menu" data-i18n="menu.shop"></button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button id="btn-rank" class="btn-menu sm" data-i18n="menu.rank"></button>
|
||||||
|
<button id="btn-settings" class="btn-menu sm" data-i18n="menu.settings"></button>
|
||||||
|
<button id="btn-howto" class="btn-menu sm" data-i18n="menu.howto"></button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-bottombar">
|
||||||
|
<span class="ver">PRIMAL RAMPAGE · v0.9 · Stone Age Survival</span>
|
||||||
|
<span class="wallet">
|
||||||
|
<span id="menu-coins" class="coins"></span>
|
||||||
|
<span id="menu-gems" class="gems"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SHOP -->
|
||||||
|
<section id="screen-shop" class="screen">
|
||||||
|
<h2 data-i18n="menu.shop"></h2>
|
||||||
|
<div class="screen-topline">
|
||||||
|
<span id="shop-coins" class="coins"></span>
|
||||||
|
<span id="shop-gems" class="gems"></span>
|
||||||
|
<button class="btn" data-back data-i18n="how.close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="tabs">
|
||||||
|
<button id="tab-weapons" class="btn tab active" data-i18n="shop.tabWeapons"></button>
|
||||||
|
<button id="tab-heroes" class="btn tab" data-i18n="shop.tabHeroes"></button>
|
||||||
|
<button id="tab-skins" class="btn tab" data-i18n="shop.tabSkins"></button>
|
||||||
|
<button id="tab-upgrades" class="btn tab">💪 <span id="lbl-tabup"></span></button>
|
||||||
|
</div>
|
||||||
|
<div id="shop-grid" class="grid"></div>
|
||||||
|
<div id="shop-bombs" class="bomb-row"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- QUESTS -->
|
||||||
|
<section id="screen-quests" class="screen narrow">
|
||||||
|
<h2 id="h2-quests">📜</h2>
|
||||||
|
<div class="screen-topline">
|
||||||
|
<span id="quest-streak" class="gems"></span>
|
||||||
|
<button class="btn" data-back data-i18n="how.close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="quest-list" class="col"></div>
|
||||||
|
<h3 id="h3-ach" style="margin-top:18px"></h3>
|
||||||
|
<div id="ach-list" class="col"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BESTIARY -->
|
||||||
|
<section id="screen-bestiary" class="screen">
|
||||||
|
<h2 id="h2-best">📖</h2>
|
||||||
|
<div class="screen-topline">
|
||||||
|
<span id="best-progress" class="gems"></span>
|
||||||
|
<button class="btn" data-back data-i18n="how.close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="bestiary-grid" class="grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SETTINGS -->
|
||||||
|
<section id="screen-settings" class="screen narrow">
|
||||||
|
<h2 data-i18n="set.title"></h2>
|
||||||
|
<div class="form-row"><label data-i18n="set.language"></label>
|
||||||
|
<select id="set-lang">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="vi">Tiếng Việt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label data-i18n="set.sfx"></label><input type="range" id="set-sfx" min="0" max="100" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.music"></label><input type="range" id="set-music" min="0" max="100" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.autofire"></label><input type="checkbox" id="set-autofire" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.autoaim"></label><input type="checkbox" id="set-autoaim" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.onehit"></label><input type="checkbox" id="set-onehit" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.shake"></label><input type="checkbox" id="set-shake" /></div>
|
||||||
|
<div class="form-row"><label data-i18n="set.name"></label><input type="text" id="set-name" maxlength="16" /></div>
|
||||||
|
<div class="row" style="display:flex;gap:10px;align-items:center;margin-top:8px">
|
||||||
|
<label for="set-shakeamt" style="min-width:130px" id="lbl-shakeamt"></label>
|
||||||
|
<input id="set-shakeamt" type="range" min="0" max="100" value="100" />
|
||||||
|
<span id="val-shakeamt" style="width:40px"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="display:flex;gap:10px;align-items:center;margin-top:8px">
|
||||||
|
<label for="set-quality" style="min-width:130px" id="lbl-quality"></label>
|
||||||
|
<select id="set-quality">
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="set-reset" class="btn danger" data-i18n="set.reset"></button>
|
||||||
|
<button class="btn primary" data-back data-i18n="set.done"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- LEADERBOARD -->
|
||||||
|
<section id="screen-rank" class="screen narrow">
|
||||||
|
<h2 data-i18n="rank.title"></h2>
|
||||||
|
<div class="rank-head rank-row">
|
||||||
|
<span>#</span><span data-i18n="rank.colPlayer"></span><span data-i18n="rank.colScore"></span><span data-i18n="rank.colWave"></span>
|
||||||
|
</div>
|
||||||
|
<div id="rank-body"></div>
|
||||||
|
<div class="form-actions"><button class="btn primary" data-back data-i18n="how.close"></button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOW TO PLAY -->
|
||||||
|
<section id="screen-howto" class="screen narrow">
|
||||||
|
<h2 data-i18n="how.title"></h2>
|
||||||
|
<ul class="how-list">
|
||||||
|
<li data-i18n="how.move"></li>
|
||||||
|
<li data-i18n="how.aim"></li>
|
||||||
|
<li data-i18n="how.ult"></li>
|
||||||
|
<li data-i18n="how.bomb"></li>
|
||||||
|
<li data-i18n="how.pause"></li>
|
||||||
|
<li data-i18n="how.onfire"></li>
|
||||||
|
<li data-i18n="how.cards"></li>
|
||||||
|
</ul>
|
||||||
|
<div class="form-actions"><button class="btn primary" data-back data-i18n="how.close"></button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ARENA SETUP -->
|
||||||
|
<section id="screen-arena-setup" class="screen narrow">
|
||||||
|
<h2 data-i18n="arena.setup"></h2>
|
||||||
|
<div class="form-row"><label data-i18n="arena.bots"></label>
|
||||||
|
<select id="arena-bots">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label data-i18n="arena.target"></label>
|
||||||
|
<select id="arena-target">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10" selected>10</option>
|
||||||
|
<option value="15">15</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" data-back data-i18n="arena.back"></button>
|
||||||
|
<button id="arena-start" class="btn primary" data-i18n="arena.start"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- IN-GAME HUD (DOM parts) -->
|
||||||
|
<div id="hud-root" class="hidden-ui">
|
||||||
|
<button id="btn-pause" class="icon-btn">⏸</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOUCH CONTROLS -->
|
||||||
|
<div id="touch-ui" class="hidden">
|
||||||
|
<div id="stick-move" class="stick"><div class="stick-knob"></div></div>
|
||||||
|
<div id="stick-aim" class="stick"><div class="stick-knob"></div></div>
|
||||||
|
<div id="touch-buttons">
|
||||||
|
<button id="tc-fire" class="tc-btn">🔥</button>
|
||||||
|
<button id="tc-ult" class="tc-btn small">★</button>
|
||||||
|
<button id="tc-bomb" class="tc-btn small">💣</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARD PICK -->
|
||||||
|
<div id="overlay-cards" class="overlay hidden">
|
||||||
|
<h2 data-i18n="card.pickTitle"></h2>
|
||||||
|
<p id="card-hint" class="hint"></p>
|
||||||
|
<div id="card-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAUSE -->
|
||||||
|
<div id="overlay-pause" class="overlay hidden">
|
||||||
|
<h2 data-i18n="hud.paused"></h2>
|
||||||
|
<div class="col">
|
||||||
|
<button id="btn-resume" class="btn big primary" data-i18n="hud.resume"></button>
|
||||||
|
<button id="btn-quit" class="btn big danger" data-i18n="hud.quit"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REVIVE -->
|
||||||
|
<div id="overlay-revive" class="overlay hidden">
|
||||||
|
<h2 id="revive-count">6</h2>
|
||||||
|
<div class="col">
|
||||||
|
<button id="btn-revive" class="btn big primary"><span data-i18n="over.revive"></span> <span id="revive-cost"></span></button>
|
||||||
|
<button id="btn-nodecline" class="btn big" data-i18n="over.menu"></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" data-i18n="over.reviveHint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GAME OVER -->
|
||||||
|
<div id="overlay-over" class="overlay hidden">
|
||||||
|
<span id="over-record" class="record hidden" data-i18n="over.newRecord"></span>
|
||||||
|
<h2 id="over-title"></h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div><label data-i18n="over.score"></label><b id="ov-score"></b></div>
|
||||||
|
<div><label data-i18n="over.wave"></label><b id="ov-wave"></b></div>
|
||||||
|
<div><label data-i18n="over.kills"></label><b id="ov-kills"></b></div>
|
||||||
|
<div><label data-i18n="over.combo"></label><b id="ov-combo"></b></div>
|
||||||
|
<div><label data-i18n="over.coinsEarned"></label><b id="ov-coins"></b></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button id="btn-retry" class="btn big primary" data-i18n="over.retry"></button>
|
||||||
|
<button id="btn-tomenu" class="btn big" data-i18n="over.menu"></button>
|
||||||
|
<button id="btn-adx2" class="btn big gem-btn hidden"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="overlay-tutorial" class="overlay hidden">
|
||||||
|
<div class="panel" style="text-align:center;max-width:420px">
|
||||||
|
<h2 id="tut-title" style="margin-bottom:8px"></h2>
|
||||||
|
<p id="tut-step" style="font-size:16px;line-height:1.5;margin-bottom:16px"></p>
|
||||||
|
<div style="display:flex;gap:10px;justify-content:center">
|
||||||
|
<button id="btn-tut-next" class="btn primary"></button>
|
||||||
|
<button id="btn-tut-skip" class="btn"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./js/i18n.js"></script>
|
||||||
|
<script src="./js/save.js?v=13"></script>
|
||||||
|
<script src="./js/util.js?v=13"></script>
|
||||||
|
<script src="./js/audio.js?v=13"></script>
|
||||||
|
<script src="./js/art.js?v=13"></script>
|
||||||
|
<script src="./js/data.js?v=13"></script>
|
||||||
|
<script src="./js/entities.js?v=13"></script>
|
||||||
|
<script src="./js/game.js?v=13"></script>
|
||||||
|
<script src="./js/ui.js?v=13"></script>
|
||||||
|
<script src="./js/main.js?v=13"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+232
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
+261
@@ -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' };
|
||||||
|
})();
|
||||||
+442
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
+1964
File diff suppressed because it is too large
Load Diff
+257
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
+185
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
+205
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
+13
@@ -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, ','); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
Reference in New Issue
Block a user