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:
Primal Rampage Bot
2026-08-23 04:26:58 +00:00
commit 2024ea48b5
14 changed files with 7164 additions and 0 deletions
+1929
View File
File diff suppressed because it is too large Load Diff
+232
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+257
View File
@@ -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
View File
@@ -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
View File
@@ -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();
})();
+827
View File
@@ -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: 'Lets 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
View File
@@ -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, ','); }
};
})();