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:
+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, ','); }
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user