Item system overhaul: rarity bands, per-item caps, source pools + 14 new passives
- Add ITEM_RARITY (common/uncommon/rare/legendary) with per-item tier caps - Weighted pools per source: elites favour common, item rooms uncommon, bosses rare/legendary; legendaries are boss-exclusive; capped items excluded - Rarity-weighted powerScore so deep common stacks inflate difficulty less - Differentiate duplicates: Grafted Trigger, Crow Bait, Hollow Bones, Twin Hearts - 14 new clean passives incl. Chill Gland (activates slowOnHit) and Hooked Sinew (activates magnetPull); revive magnetPull in pickups - Rarity-tinted pedestal glow + nameplates, rarity-sorted HUD implant tray, dynamic help implant pages (8 -> 9) - Generate 14 item icons via gen_assets.py; bump asset cache to v=38 - Update smoke tests for 60 items, per-item caps, pools, weighted power
This commit is contained in:
1 parent
a02cff52cf
commit
4f46d54e99
28 files changed
+356
-187
No files matched your search
+1
-1
@@ -593,7 +593,7 @@ function onBossDeath(e) {
|
||||
addScore(500 + G.floor * 100);
|
||||
addShake(12);
|
||||
// boss reward: item pedestal + a real weapon drop + stairs down
|
||||
spawnItemPedestal(W / 2 - 90, H / 2);
|
||||
spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss');
|
||||
const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff
|
||||
spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id });
|
||||
spawnPickup('stairs', W / 2, H / 2 + 80);
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ function killEnemy(e, ang) {
|
||||
gemVal += irand(3, 5);
|
||||
spawnGems(e.x, e.y, gemVal);
|
||||
if (chance(0.18 + luck * 0.1)) {
|
||||
spawnPickup('item', e.x, e.y, { iid: randomItemId() });
|
||||
spawnPickup('item', e.x, e.y, { iid: rollItemId('elite', G.floor) });
|
||||
} else if (chance(0.6 + luck * 0.2)) {
|
||||
spawnPickup('ammo', e.x, e.y);
|
||||
}
|
||||
|
||||
+20
-13
@@ -4,13 +4,16 @@
|
||||
|
||||
const HELP_BUTTON = { x: W - 104, y: 24, w: 80, h: 26 };
|
||||
|
||||
// Implant pages are generated from the live ITEMS roster so the manual never
|
||||
// drifts from the game data; the count and page count are dynamic.
|
||||
const HELP_ITEM_PAGE_SIZE = 23;
|
||||
const HELP_ITEM_PAGE_COUNT = Math.ceil(Object.keys(ITEMS).length / HELP_ITEM_PAGE_SIZE);
|
||||
const HELP_PAGES = [
|
||||
{ title: 'CONTROLS' },
|
||||
{ title: 'THE LOOP' },
|
||||
{ title: 'ARSENAL' },
|
||||
{ title: 'MUTATIONS' },
|
||||
{ title: 'IMPLANTS I' },
|
||||
{ title: 'IMPLANTS II' },
|
||||
...Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => ({ title: 'IMPLANTS ' + ['I', 'II', 'III', 'IV', 'V', 'VI'][i] || ('P' + (i + 1)) })),
|
||||
{ title: 'BESTIARY' },
|
||||
{ title: 'PRESSURE' },
|
||||
];
|
||||
@@ -181,31 +184,35 @@ function drawHelpPerkList(ctx, px, py) {
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpItemList(ctx, px, py, half) {
|
||||
function drawHelpItemList(ctx, px, py, page) {
|
||||
const x = px + 40;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
const total = Object.keys(ITEMS).length;
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('46 IMPLANTS · STACK TO TIER IX · DUPLICATE ROLLS FAVORED · TIER IX → +150 SCORE', x, ty);
|
||||
ctx.fillText(total + ' IMPLANTS · PER-ITEM TIER CAPS · RARITY-WEIGHTED POOLS · DUPLICATE ROLLS FAVORED', x, ty);
|
||||
ty += 11;
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
||||
ctx.fillText('STICKINESS RAMPS 10% PER DISTINCT UPGRADABLE ITEM OWNED (CAP 50%) · PAGE ' + (half + 1) + ' OF 2', x, ty + 11);
|
||||
ctx.fillText('ELITES FAVOUR COMMON · ITEM ROOMS FAVOUR UNCOMMON · BOSSES FAVOUR RARE/LEGENDARY · PAGE ' + (page + 1) + ' OF ' + HELP_ITEM_PAGE_COUNT, x, ty + 11);
|
||||
ty += 34;
|
||||
const entries = Object.entries(ITEMS);
|
||||
const halfEntries = entries.slice(half * 23, half * 23 + 23);
|
||||
const pageEntries = entries.slice(page * HELP_ITEM_PAGE_SIZE, page * HELP_ITEM_PAGE_SIZE + HELP_ITEM_PAGE_SIZE);
|
||||
const colW = (HELP_PANEL.w - 80) / 2;
|
||||
for (let i = 0; i < halfEntries.length; i++) {
|
||||
for (let i = 0; i < pageEntries.length; i++) {
|
||||
const col = Math.floor(i / 12), r = i % 12;
|
||||
const lx = x + col * colW, ly = ty + r * 20;
|
||||
ctx.fillStyle = '#e3b746'; ctx.font = hfont(8, true);
|
||||
ctx.fillText(halfEntries[i][1].name.toUpperCase(), lx, ly);
|
||||
const [iid, item] = pageEntries[i];
|
||||
const rar = ITEM_RARITY[item.rarity];
|
||||
ctx.fillStyle = rar.color; ctx.font = hfont(8, true);
|
||||
ctx.fillText(item.name.toUpperCase(), lx, ly);
|
||||
ctx.fillStyle = '#5f4d4d'; ctx.font = hfont(7, true);
|
||||
ctx.fillText(item.rarity.toUpperCase().slice(0, 3) + '·' + item.cap, lx, ly + 12);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false);
|
||||
ctx.fillText(fit(halfEntries[i][1].desc.toUpperCase(), 40), lx, ly + 12);
|
||||
ctx.fillText(fit(item.desc.toUpperCase(), 34), lx + 52, ly + 12);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpItemList1(ctx, px, py) { drawHelpItemList(ctx, px, py, 0); }
|
||||
function drawHelpItemList2(ctx, px, py) { drawHelpItemList(ctx, px, py, 1); }
|
||||
const HELP_ITEM_RENDERERS = Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => (c, x, y) => drawHelpItemList(c, x, y, i));
|
||||
|
||||
function drawHelpBestiary(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
@@ -276,7 +283,7 @@ function drawHelpPressure(ctx, px, py) {
|
||||
], HELP_PANEL.w - 80);
|
||||
}
|
||||
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, drawHelpItemList1, drawHelpItemList2, drawHelpBestiary, drawHelpPressure];
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpBestiary, drawHelpPressure];
|
||||
|
||||
function drawPauseHelp(ctx) {
|
||||
ctx.fillStyle = 'rgba(4,2,3,0.84)';
|
||||
|
||||
@@ -109,7 +109,9 @@ function drawHUD(ctx) {
|
||||
ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125 + vOff);
|
||||
}
|
||||
|
||||
const allItemEntries = Object.entries(p.items);
|
||||
const rarityRank = { legendary: 0, rare: 1, uncommon: 2, common: 3 };
|
||||
const allItemEntries = Object.entries(p.items).sort((a, b) =>
|
||||
(rarityRank[ITEMS[a[0]].rarity] - rarityRank[ITEMS[b[0]].rarity]) || (b[1] - a[1]));
|
||||
const maxVisibleItems = allItemEntries.length > 24 ? 23 : 24;
|
||||
const itemEntries = allItemEntries.slice(0, maxVisibleItems);
|
||||
if (itemEntries.length) {
|
||||
|
||||
+129
-60
@@ -1,73 +1,142 @@
|
||||
// ---- Isaac-style stacking passive items ----
|
||||
|
||||
const ITEMS = {
|
||||
hollowpoints: { name: 'Hollow Points', desc: '+25% damage', apply: s => { s.dmgMul *= 1.25; } },
|
||||
twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', apply: s => { s.rateMul *= 1.20; } },
|
||||
scalpel: { name: 'Scalpel', desc: '+25% shot speed', apply: s => { s.shotSpeedMul *= 1.25; } },
|
||||
leadmarrow: { name: 'Lead Marrow', desc: '+25% range', apply: s => { s.rangeMul *= 1.25; } },
|
||||
piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', apply: s => { s.pierce += 1; } },
|
||||
ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', apply: s => { s.bounce += 1; } },
|
||||
splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', apply: s => { s.split += 1; } },
|
||||
hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', apply: s => { s.fan += 1; } },
|
||||
homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', apply: s => { s.homing = (s.homing || 0) + 1; } },
|
||||
orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', apply: s => { s.orbitals += 1; } },
|
||||
dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', apply: s => { s.lifestealChance += 0.04; } },
|
||||
volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } },
|
||||
backstabber: { name: 'Backstabber', desc: '+1 rear shot', apply: s => { s.rear = (s.rear || 0) + 1; } },
|
||||
splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', apply: s => { s.splinter = (s.splinter || 0) + 2; } },
|
||||
ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||
luckycoin: { name: 'Lucky Coin', desc: 'Better drops', apply: s => { s.luck += 0.2; } },
|
||||
magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', apply: s => { s.magnet *= 1.6; } },
|
||||
bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } },
|
||||
ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
|
||||
chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', apply: s => { s.chain += 1; } },
|
||||
mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', apply: s => { s.mortar += 1; } },
|
||||
bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } },
|
||||
marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } },
|
||||
hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', apply: s => { s.critMul += 0.50; } },
|
||||
bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', apply: s => { s.crit += 0.06; } },
|
||||
flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', apply: s => { s.bleed += 0.20; } },
|
||||
emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', apply: s => { s.igniteChance += 0.20; } },
|
||||
acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', apply: s => { s.acidOnHit += 0.12; } },
|
||||
hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', apply: s => { s.pullOnHit += 0.18; } },
|
||||
sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } },
|
||||
graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate', apply: s => { s.rateMul *= 1.18; } },
|
||||
deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', apply: s => { s.frenzy += 0.12; } },
|
||||
orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } },
|
||||
tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', apply: s => { s.armor += 0.08 / 0.92; } },
|
||||
deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', apply: s => { s.invBonus += 0.25; } },
|
||||
hollowbones: { name: 'Hollow Bones', desc: '+12% move speed', apply: s => { s.speedMul *= 1.12; } },
|
||||
boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } },
|
||||
wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', apply: s => { s.roomHeal += 1; } },
|
||||
spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', apply: s => { s.thorns += 8; } },
|
||||
secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||
spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', apply: s => { s.retaliate += 10; } },
|
||||
twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal 1 heart', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
|
||||
brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } },
|
||||
crowbait: { name: 'Crow Bait', desc: '+25% drop luck', apply: s => { s.luck += 0.25; } },
|
||||
gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', apply: s => { s.xpMul *= 1.20; } },
|
||||
rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } },
|
||||
const ITEM_RARITY = {
|
||||
common: { weight: 100, power: 0.5, color: '#c9b8ae' },
|
||||
uncommon: { weight: 55, power: 1.0, color: '#6fb1c0' },
|
||||
rare: { weight: 25, power: 1.5, color: '#c9a227' },
|
||||
legendary: { weight: 8, power: 3.0, color: '#e2472f' },
|
||||
};
|
||||
|
||||
// Stickiness ramps with commitment: 10% per distinct upgradable item owned,
|
||||
// capped at 50%. Your first pickup barely biases the pool, so early drops vary.
|
||||
function randomItemId() {
|
||||
const p = G.player;
|
||||
const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < ITEM_LEVEL_CAP) : [];
|
||||
const stick = Math.min(0.5, owned.length * 0.10);
|
||||
if (owned.length && chance(stick)) return choice(owned);
|
||||
return choice(Object.keys(ITEMS));
|
||||
}
|
||||
const ITEMS = {
|
||||
// ---- common ----
|
||||
hollowpoints: { name: 'Hollow Points', desc: '+25% damage', rarity: 'common', cap: 6, apply: s => { s.dmgMul *= 1.25; } },
|
||||
twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', rarity: 'common', cap: 6, apply: s => { s.rateMul *= 1.20; } },
|
||||
graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate, +6% shot speed', rarity: 'common', cap: 6, apply: s => { s.rateMul *= 1.18; s.shotSpeedMul *= 1.06; } },
|
||||
scalpel: { name: 'Scalpel', desc: '+25% shot speed', rarity: 'common', cap: 8, apply: s => { s.shotSpeedMul *= 1.25; } },
|
||||
leadmarrow: { name: 'Lead Marrow', desc: '+25% range', rarity: 'common', cap: 8, apply: s => { s.rangeMul *= 1.25; } },
|
||||
bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', rarity: 'common', cap: 8, apply: s => { s.crit += 0.06; } },
|
||||
hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', rarity: 'common', cap: 6, apply: s => { s.critMul += 0.50; } },
|
||||
luckycoin: { name: 'Lucky Coin', desc: 'Better drops', rarity: 'common', cap: 5, apply: s => { s.luck += 0.2; } },
|
||||
crowbait: { name: 'Crow Bait', desc: '+15% luck, +25% pickup radius', rarity: 'common', cap: 5, apply: s => { s.luck += 0.15; s.magnet *= 1.25; } },
|
||||
magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', rarity: 'common', cap: 3, apply: s => { s.magnet *= 1.6; } },
|
||||
gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', rarity: 'common', cap: 4, apply: s => { s.xpMul *= 1.20; } },
|
||||
hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } },
|
||||
brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } },
|
||||
ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', rarity: 'common', cap: 8, apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||
tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08 / 0.92; } },
|
||||
bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } },
|
||||
|
||||
// ---- uncommon ----
|
||||
bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', rarity: 'uncommon', cap: 5, apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } },
|
||||
marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', rarity: 'uncommon', cap: 6, apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } },
|
||||
piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', rarity: 'uncommon', cap: 5, apply: s => { s.pierce += 1; } },
|
||||
ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', rarity: 'uncommon', cap: 5, apply: s => { s.bounce += 1; } },
|
||||
flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', rarity: 'uncommon', cap: 5, apply: s => { s.bleed += 0.20; } },
|
||||
emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', rarity: 'uncommon', cap: 5, apply: s => { s.igniteChance += 0.20; } },
|
||||
acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', rarity: 'uncommon', cap: 6, apply: s => { s.acidOnHit += 0.12; } },
|
||||
hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', rarity: 'uncommon', cap: 5, apply: s => { s.pullOnHit += 0.18; } },
|
||||
sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', rarity: 'uncommon', cap: 5, apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } },
|
||||
chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', rarity: 'uncommon', cap: 6, apply: s => { s.chain += 1; } },
|
||||
mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', rarity: 'uncommon', cap: 5, apply: s => { s.mortar += 1; } },
|
||||
homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', rarity: 'uncommon', cap: 4, apply: s => { s.homing = (s.homing || 0) + 1; } },
|
||||
dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', rarity: 'uncommon', cap: 5, apply: s => { s.lifestealChance += 0.04; } },
|
||||
spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', rarity: 'uncommon', cap: 5, apply: s => { s.thorns += 8; } },
|
||||
spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', rarity: 'uncommon', cap: 5, apply: s => { s.retaliate += 10; } },
|
||||
orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', rarity: 'uncommon', cap: 5, apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } },
|
||||
ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', rarity: 'uncommon', cap: 5, apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
|
||||
|
||||
// ---- rare ----
|
||||
splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', rarity: 'rare', cap: 3, apply: s => { s.split += 1; } },
|
||||
hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', rarity: 'rare', cap: 3, apply: s => { s.fan += 1; } },
|
||||
backstabber: { name: 'Backstabber', desc: '+1 rear shot', rarity: 'rare', cap: 3, apply: s => { s.rear = (s.rear || 0) + 1; } },
|
||||
splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', rarity: 'rare', cap: 3, apply: s => { s.splinter = (s.splinter || 0) + 2; } },
|
||||
orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', rarity: 'rare', cap: 3, apply: s => { s.orbitals += 1; } },
|
||||
volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', rarity: 'rare', cap: 3, apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } },
|
||||
deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', rarity: 'rare', cap: 3, apply: s => { s.frenzy += 0.12; } },
|
||||
boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', rarity: 'rare', cap: 3, apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } },
|
||||
wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', rarity: 'rare', cap: 3, apply: s => { s.roomHeal += 1; } },
|
||||
secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||
twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal to full', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 2; p.hp = s.maxHp; } },
|
||||
deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', rarity: 'rare', cap: 3, apply: s => { s.invBonus += 0.25; } },
|
||||
rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', rarity: 'rare', cap: 2, apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } },
|
||||
|
||||
// ---- phase 2 additions (clean, no new engine hooks) ----
|
||||
chillgland: { name: 'Chill Gland', desc: '18% chance to chill', rarity: 'uncommon', cap: 5, apply: s => { s.slowOnHit += 0.18; } },
|
||||
hookedsinew: { name: 'Hooked Sinew', desc: '+30% pickup pull speed, +10% radius', rarity: 'common', cap: 5, apply: s => { s.magnetPull *= 1.30; s.magnet *= 1.10; } },
|
||||
gyroscopicribs:{ name: 'Gyroscopic Ribs', desc: '+2 bounce, +10% range', rarity: 'uncommon', cap: 4, apply: s => { s.bounce += 2; s.rangeMul *= 1.10; } },
|
||||
marrowpiston: { name: 'Marrow Piston', desc: '+30% knockback, +12% attack size', rarity: 'common', cap: 5, apply: s => { s.knockbackMul *= 1.30; s.sizeMul *= 1.12; } },
|
||||
splitcortex: { name: 'Split Cortex', desc: '+1 pierce, +1 chain', rarity: 'rare', cap: 3, apply: s => { s.pierce += 1; s.chain += 1; } },
|
||||
gristlecord: { name: 'Gristle Cord', desc: '+0.12s hurt immunity, +4% armor', rarity: 'uncommon', cap: 4, apply: s => { s.invBonus += 0.12; s.armor += 0.04; } },
|
||||
renderedfat: { name: 'Rendered Fat', desc: '+½ heart, +4% armor', rarity: 'common', cap: 6, apply: (s, p) => { s.maxHp += 1; s.armor += 0.04; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||
whipcordtendon:{ name: 'Whipcord Tendon', desc: '+20% range, +8% fire rate', rarity: 'uncommon', cap: 5, apply: s => { s.rangeMul *= 1.20; s.rateMul *= 1.08; } },
|
||||
rusteddiadem: { name: 'Rusted Diadem', desc: '+40% orbital speed', rarity: 'uncommon', cap: 5, apply: s => { s.orbSpeedMul *= 1.40; } },
|
||||
gorgedtick: { name: 'Gorged Tick', desc: '+15% XP, +10% luck', rarity: 'common', cap: 5, apply: s => { s.xpMul *= 1.15; s.luck += 0.10; } },
|
||||
bonemealpowder:{ name: 'Bonemeal Powder', desc: '+15% damage, +15% shot speed', rarity: 'common', cap: 6, apply: s => { s.dmgMul *= 1.15; s.shotSpeedMul *= 1.15; } },
|
||||
rimedfang: { name: 'Rimed Fang', desc: '+4% crit chance, +25% crit damage', rarity: 'common', cap: 6, apply: s => { s.crit += 0.04; s.critMul += 0.25; } },
|
||||
butcherstwine: { name: 'Butcher\'s Twine', desc: '+12% bleed, +8% fire rate', rarity: 'uncommon', cap: 5, apply: s => { s.bleed += 0.12; s.rateMul *= 1.08; } },
|
||||
cindersump: { name: 'Cinder Sump', desc: '+12% ignite, +12% acid', rarity: 'uncommon', cap: 5, apply: s => { s.igniteChance += 0.12; s.acidOnHit += 0.12; } },
|
||||
};
|
||||
|
||||
const ITEM_LEVEL_CAP = 9;
|
||||
function itemCap(iid) { return ITEMS[iid].cap ?? ITEM_LEVEL_CAP; }
|
||||
|
||||
function romanNum(n) { return ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'][n - 1] || ('Lv' + n); }
|
||||
|
||||
// ---- pools: hybrid weighted table, legendaries are boss-only ----
|
||||
const ITEM_POOL_WEIGHTS = {
|
||||
elite: { common: 100, uncommon: 45, rare: 8, legendary: 0 },
|
||||
room: { common: 45, uncommon: 85, rare: 40, legendary: 0 },
|
||||
boss: { common: 12, uncommon: 45, rare: 70, legendary: 45 },
|
||||
};
|
||||
const ITEM_POOL_STICK = { elite: 1.0, room: 0.7, boss: 0.35 };
|
||||
|
||||
function rollItemId(source, floor) {
|
||||
const p = G.player;
|
||||
const w = ITEM_POOL_WEIGHTS[source] || ITEM_POOL_WEIGHTS.room;
|
||||
const f = floor || G.floor || 1;
|
||||
|
||||
// distinct upgradable items owned, not yet at their own cap
|
||||
const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < itemCap(iid)) : [];
|
||||
const stick = Math.min(0.5, owned.length * 0.10) * (ITEM_POOL_STICK[source] || 1);
|
||||
if (owned.length && chance(stick)) return choice(owned);
|
||||
|
||||
// floor-scaled rarity table, boss-excluding legendaries from non-boss sources
|
||||
const floorScale = {
|
||||
common: Math.max(0.4, 1 - f * 0.05),
|
||||
uncommon: 1,
|
||||
rare: 1 + f * 0.08,
|
||||
legendary: 1 + f * 0.10,
|
||||
};
|
||||
let total = 0;
|
||||
const weights = {};
|
||||
for (const r in w) {
|
||||
const rw = w[r] * floorScale[r];
|
||||
weights[r] = rw;
|
||||
total += rw;
|
||||
}
|
||||
let roll = Math.random() * total;
|
||||
let band = 'common';
|
||||
for (const r in weights) { roll -= weights[r]; if (roll <= 0) { band = r; break; } }
|
||||
|
||||
// candidates in the band that are not already capped
|
||||
let pool = Object.keys(ITEMS).filter(iid => ITEMS[iid].rarity === band && (p ? (p.items[iid] || 0) < itemCap(iid) : true));
|
||||
// if the band is exhausted, fall back to any unmaxed item
|
||||
if (!pool.length) pool = Object.keys(ITEMS).filter(iid => p ? (p.items[iid] || 0) < itemCap(iid) : true);
|
||||
// absolute fallback: full pool (everything capped -> giveItem converts to score)
|
||||
if (!pool.length) pool = Object.keys(ITEMS);
|
||||
return choice(pool);
|
||||
}
|
||||
|
||||
// legacy alias used by tests / older call sites
|
||||
function randomItemId() { return rollItemId('room', G.floor); }
|
||||
|
||||
// duplicate pickups upgrade the item's quality (effect re-applies per tier)
|
||||
function giveItem(iid) {
|
||||
const p = G.player;
|
||||
const item = ITEMS[iid];
|
||||
const lvl = p.items[iid] || 0;
|
||||
if (lvl >= ITEM_LEVEL_CAP) {
|
||||
const cap = itemCap(iid);
|
||||
if (lvl >= cap) {
|
||||
addScore(150);
|
||||
spawnGems(p.x, p.y, 5);
|
||||
addToast(item.name + ' MAX', 'already mastered — +150 score');
|
||||
@@ -87,8 +156,8 @@ function giveItem(iid) {
|
||||
refreshOrbitals(p);
|
||||
}
|
||||
|
||||
// spawn an item pedestal (item room / boss reward)
|
||||
function spawnItemPedestal(x, y, iid) {
|
||||
// spawn an item pedestal (item room / boss reward); source drives the pool roll
|
||||
function spawnItemPedestal(x, y, iid, source) {
|
||||
spawnPickup('itemspot', x, y);
|
||||
spawnPickup('item', x, y, { iid: iid || randomItemId() });
|
||||
spawnPickup('item', x, y, { iid: iid || rollItemId(source || 'room', G.floor) });
|
||||
}
|
||||
+5
-4
@@ -51,7 +51,7 @@ function updatePickups(dt) {
|
||||
// gems & small pickups fly to the player inside magnet radius
|
||||
if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) {
|
||||
const a = angleTo(k.x, k.y, p.x, p.y);
|
||||
const sp = lerp(320, 60, d / magR);
|
||||
const sp = lerp(320, 60, d / magR) * p.stats.magnetPull;
|
||||
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
|
||||
}
|
||||
|
||||
@@ -164,12 +164,13 @@ function drawPickups(ctx) {
|
||||
} else if (k.type === 'itemspot') {
|
||||
Sprites.draw(ctx, 'pedestal', k.x, k.y, 0, 64);
|
||||
} else if (k.type === 'item') {
|
||||
const rarColor = ITEM_RARITY[ITEMS[k.iid].rarity].color;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.2 + Math.sin(k.t * 3) * 0.07;
|
||||
ctx.fillStyle = '#ffe075';
|
||||
ctx.fillStyle = rarColor;
|
||||
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill();
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.strokeStyle = '#ffe9a6'; ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = rarColor; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 25 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke();
|
||||
ctx.restore();
|
||||
Sprites.draw(ctx, 'i_' + k.iid, k.x, k.y - 28 + bob, 0, 64);
|
||||
@@ -186,7 +187,7 @@ function drawPickups(ctx) {
|
||||
// hover nameplates when close
|
||||
if (dist2(k.x, k.y, p.x, p.y) < 90 * 90) {
|
||||
if (k.type === 'weapon') drawPickupLabel(ctx, WEAPONS[k.wid].name, k.x, k.y - 44, '#ffd060');
|
||||
else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, '#c9a227');
|
||||
else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, ITEM_RARITY[ITEMS[k.iid].rarity].color);
|
||||
else if (k.type === 'stairs') drawPickupLabel(ctx, 'STAIRS DOWN', k.x, k.y - 52, '#e8e0d0');
|
||||
else if (k.type === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060');
|
||||
}
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ function enterRoom(gx, gy) {
|
||||
room.bossSpawned = true;
|
||||
Music.playBoss(boss.bossKind);
|
||||
} else if (room.type === 'item') {
|
||||
spawnItemPedestal(W / 2 - 45, H / 2);
|
||||
spawnItemPedestal(W / 2 - 45, H / 2, null, 'room');
|
||||
// item rooms sometimes also stock a weapon
|
||||
if (chance(0.35)) {
|
||||
const w = rollWeaponDrop(G.floor);
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ const SfxBank = {
|
||||
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
||||
const file = this.FILES[name];
|
||||
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=37')
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=38')
|
||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(bytes => this.ctx.decodeAudioData(bytes))
|
||||
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
||||
|
||||
+4
-1
@@ -36,6 +36,9 @@ const SPRITE_MANIFEST = [
|
||||
'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate',
|
||||
'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine',
|
||||
'i_crowbait', 'i_gorgingleech', 'i_rerollrib',
|
||||
'i_chillgland', 'i_hookedsinew', 'i_gyroscopicribs', 'i_marrowpiston', 'i_splitcortex',
|
||||
'i_gristlecord', 'i_renderedfat', 'i_whipcordtendon', 'i_rusteddiadem', 'i_gorgedtick',
|
||||
'i_bonemealpowder', 'i_rimedfang', 'i_butcherstwine', 'i_cindersump',
|
||||
// full 64px/128px eight-direction action atlases
|
||||
'player_sheet', 'player_legs_sheet',
|
||||
'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet',
|
||||
@@ -73,7 +76,7 @@ const Sprites = {
|
||||
const img = new Image();
|
||||
img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.src = 'assets/' + name + '.png?v=37';
|
||||
img.src = 'assets/' + name + '.png?v=38';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+2
-2
@@ -58,13 +58,13 @@ function pressureDropUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRES
|
||||
function pressureGain() { return pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; }
|
||||
function pressureDropScale() { return pressureDropUnits(G.pressureDial) / PRESSURE_DROP_BASE; }
|
||||
|
||||
// total player power: perks drafted (level - 1) + item tiers owned.
|
||||
// total player power: perks drafted (level - 1) + item tiers owned, weighted by rarity.
|
||||
// monsters scale against this so difficulty tracks the build.
|
||||
function powerScore() {
|
||||
const p = G.player;
|
||||
if (!p) return 0;
|
||||
let n = (p.level - 1) * PERK_POWER_WEIGHT;
|
||||
for (const iid in p.items) n += p.items[iid];
|
||||
for (const iid in p.items) n += p.items[iid] * ITEM_RARITY[ITEMS[iid].rarity].power;
|
||||
return Math.max(0, n);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user