Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa58b5678 | ||
|
|
8991bde943 | ||
|
|
2cf8dc89fe |
No files matched your search
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.9.6] — 2026-08-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Cleaver Cadence** — wider, harder whirl (`sweepRange` 90→110, `sweepArc` 2.4→2.85, `dmg` 34→42). Magazine stays 26 / refill 10. The butcher spins ~2.5 turns during the swing. Dual-arc blood/spark VFX and hit gibs; yellow gun muzzle is suppressed.
|
||||||
|
- **Elite Splitters** — now leave 2 Minis on death, same as a normal Splitter (instant, no telegraph).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HD Remaster first boss-room hitch** — `drawBossLoad` now first-draws the 4× floor/wall caches and blits the boss sheet onto the game canvas under the overlay, so combat starts without a first-frame GPU stall. SD and re-visits are unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.9.5] — 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Boss-room loading overlay** — first visit of a boss room holds combat in `bossload` for at least 1s while the boss action sheet is warmed (decode + GPU upload) so the roar is not cut by the hitch. Re-entering a visited boss room skips the overlay. A failed sheet cannot deadlock the screen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Elite Brood Sac summons** — an elite sac now seeds 4–6 minis per cycle (normal remains 2). Wave elite rolls still exclude Brood Sac; debug/other elite paths are the intended case. Death burst is unchanged (4).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Redundant RUN DATA pressure multiplier** — the `×N.NN` number next to score is gone; `PRESSURE NN%` and the bar remain.
|
||||||
|
|
||||||
|
|
||||||
## [0.9.1] — 2026-08-13
|
## [0.9.1] — 2026-08-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@ Ranged kiter. Holds 190–250 px away from you — approaches if you're farther
|
|||||||
| XP | 1 |
|
| XP | 1 |
|
||||||
| First appears | Floor 2 (spawn weight 16) |
|
| First appears | Floor 2 (spawn weight 16) |
|
||||||
|
|
||||||
Chaser that **splits into 2 Minis on death** (non-elite Splitters only). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here.
|
Chaser that **splits into 2 Minis on death** (elite and normal). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here.
|
||||||
|
|
||||||
### Mini
|
### Mini
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ Its 180px aura grants +35% speed and heals nearby non-boss monsters ~1.8% max HP
|
|||||||
| XP | 4 |
|
| XP | 4 |
|
||||||
| First appears | Floor 7 (spawn weight 6) |
|
| First appears | Floor 7 (spawn weight 6) |
|
||||||
|
|
||||||
Spawns 2 Minis every 4s and 4 on death. At most two are wave-selected; the brood population cap is 40. Cannot be elite and is excluded from thin halls.
|
Spawns 2 Minis every 4s (elite: 4–6 every 3s) and 4 on death. At most two are wave-selected; the brood population cap is 40. Excluded from normal wave elite rolls and thin halls; an elite sac (debug or other spawn paths) still uses the elite summon count.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -271,7 +271,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
|||||||
- **Top-left — BUTCHER // VITALS:** level, hearts (red, 2 HP per heart; half containers render as half outlines) and shield hearts (cyan, 2 shield HP per heart), shown on up to two rows, XP bar, active weapon + ammo (red when below 25%), holstered weapon with `[R]` swap hint.
|
- **Top-left — BUTCHER // VITALS:** level, hearts (red, 2 HP per heart; half containers render as half outlines) and shield hearts (cyan, 2 shield HP per heart), shown on up to two rows, XP bar, active weapon + ammo (red when below 25%), holstered weapon with `[R]` swap hint.
|
||||||
- **Item tray** (below vitals): up to 24 item icons with tier numerals I–IX.
|
- **Item tray** (below vitals): up to 24 item icons with tier numerals I–IX.
|
||||||
- **Top-right — SECTOR MAP:** minimap. White = current room, red = boss, gold = item, gray = cleared, maroon = uncleared. Includes floor number.
|
- **Top-right — SECTOR MAP:** minimap. White = current room, red = boss, gold = item, gray = cleared, maroon = uncleared. Includes floor number.
|
||||||
- **Bottom-right — RUN DATA:** floor, kills, score (with the live pressure multiplier), PRESSURE % (with the dial setting), STREAK, pressure bar.
|
- **Bottom-right — RUN DATA:** floor, kills, score, PRESSURE % (with the dial setting), STREAK, pressure bar.
|
||||||
- **HUD opacity** is adjustable from the pause menu (`;` / `'` or the HUD slider, 25%–100%). The boss HP bar and toast notifications always stay fully opaque.
|
- **HUD opacity** is adjustable from the pause menu (`;` / `'` or the HUD slider, 25%–100%). The boss HP bar and toast notifications always stay fully opaque.
|
||||||
- **Bottom-center:** boss HP bar (during boss fights).
|
- **Bottom-center:** boss HP bar (during boss fights).
|
||||||
- **Top-center:** toast notifications (pickups, floor intros, boss events).
|
- **Top-center:** toast notifications (pickups, floor intros, boss events).
|
||||||
@@ -281,6 +281,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
|||||||
| Screen | What it offers |
|
| Screen | What it offers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit |
|
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit |
|
||||||
|
| **Boss approach** | Full-screen hold on first boss-room entry: boss name + FRESH MEAT APPROACHES, ≥1s (HD first-draw paid under the overlay), then combat |
|
||||||
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
||||||
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
|
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
|
||||||
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
||||||
|
|||||||
+5
-5
@@ -106,18 +106,18 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but
|
|||||||
|
|
||||||
### Cleaver Cadence
|
### Cleaver Cadence
|
||||||
|
|
||||||
> *A wide, dramatic melee sweep*
|
> *A wider, gory whirl of steel*
|
||||||
|
|
||||||
| Stat | Value |
|
| Stat | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Damage | 34 |
|
| Damage | 42 |
|
||||||
| Fire interval | 0.55 s |
|
| Fire interval | 0.55 s |
|
||||||
| Reach | 90 px |
|
| Reach | 110 px |
|
||||||
| Arc | 2.4 rad (~137°) |
|
| Arc | 2.85 rad (~163°) |
|
||||||
| Ammo | 26 (refill 10 per pickup) |
|
| Ammo | 26 (refill 10 per pickup) |
|
||||||
| Recoil | 0.95 (heavy) |
|
| Recoil | 0.95 (heavy) |
|
||||||
|
|
||||||
Melee sweep: one wide swing across the aim direction hits every enemy within 90 px inside a ~137° arc for 34 damage with heavy knockback.
|
Melee sweep: one wide whirl across the aim direction hits every enemy within 110 px inside a ~163° arc for 42 damage with heavy knockback. The butcher spins ~2.5 turns during the swing. Blood and sparks ride the arc; there is no yellow gun muzzle.
|
||||||
|
|
||||||
### Sawblade Launcher
|
### Sawblade Launcher
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -6,32 +6,32 @@
|
|||||||
<meta name="theme-color" content="#0a0305">
|
<meta name="theme-color" content="#0a0305">
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
||||||
<title>MEATSLICER</title>
|
<title>MEATSLICER</title>
|
||||||
<link rel="stylesheet" href="css/style.css?v=59">
|
<link rel="stylesheet" href="css/style.css?v=61">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<canvas id="game" width="960" height="640"></canvas>
|
<canvas id="game" width="960" height="640"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<script src="js/utils.js?v=59"></script>
|
<script src="js/utils.js?v=61"></script>
|
||||||
<script src="js/state.js?v=59"></script>
|
<script src="js/state.js?v=61"></script>
|
||||||
<script src="js/input.js?v=59"></script>
|
<script src="js/input.js?v=61"></script>
|
||||||
<script src="js/sfx.js?v=59"></script>
|
<script src="js/sfx.js?v=61"></script>
|
||||||
<script src="js/sfxbank.js?v=59"></script>
|
<script src="js/sfxbank.js?v=61"></script>
|
||||||
<script src="js/audio.js?v=59"></script>
|
<script src="js/audio.js?v=61"></script>
|
||||||
<script src="js/sprites.js?v=59"></script>
|
<script src="js/sprites.js?v=61"></script>
|
||||||
<script src="js/particles.js?v=59"></script>
|
<script src="js/particles.js?v=61"></script>
|
||||||
<script src="js/weapons.js?v=59"></script>
|
<script src="js/weapons.js?v=61"></script>
|
||||||
<script src="js/bullets.js?v=59"></script>
|
<script src="js/bullets.js?v=61"></script>
|
||||||
<script src="js/pickups.js?v=59"></script>
|
<script src="js/pickups.js?v=61"></script>
|
||||||
<script src="js/items.js?v=59"></script>
|
<script src="js/items.js?v=61"></script>
|
||||||
<script src="js/perks.js?v=59"></script>
|
<script src="js/perks.js?v=61"></script>
|
||||||
<script src="js/enemies.js?v=59"></script>
|
<script src="js/enemies.js?v=61"></script>
|
||||||
<script src="js/bosses.js?v=59"></script>
|
<script src="js/bosses.js?v=61"></script>
|
||||||
<script src="js/rooms.js?v=59"></script>
|
<script src="js/rooms.js?v=61"></script>
|
||||||
<script src="js/player.js?v=59"></script>
|
<script src="js/player.js?v=61"></script>
|
||||||
<script src="js/hud.js?v=59"></script>
|
<script src="js/hud.js?v=61"></script>
|
||||||
<script src="js/help.js?v=59"></script>
|
<script src="js/help.js?v=61"></script>
|
||||||
<script src="js/debug.js?v=59"></script>
|
<script src="js/debug.js?v=61"></script>
|
||||||
<script src="js/main.js?v=59"></script>
|
<script src="js/main.js?v=61"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+8
-4
@@ -100,6 +100,8 @@ function spawnPosAwayFromPlayer() {
|
|||||||
const y = rand(a.y0 + 40, a.y1 - 40);
|
const y = rand(a.y0 + 40, a.y1 - 40);
|
||||||
if (dist(x, y, p.x, p.y) <= safeDist) continue;
|
if (dist(x, y, p.x, p.y) <= safeDist) continue;
|
||||||
if (nearAnyDoor(x, y)) continue;
|
if (nearAnyDoor(x, y)) continue;
|
||||||
|
const obstacles = G.cur && G.cur.obstacles;
|
||||||
|
if (obstacles && obstacles.some(o => dist(x, y, o.x, o.y) < 16 + o.r)) continue;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
// fallback: a bounded central region, valid even in the thin hall variants
|
// fallback: a bounded central region, valid even in the thin hall variants
|
||||||
@@ -376,7 +378,7 @@ function killEnemy(e, ang) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// splitter splits
|
// splitter splits
|
||||||
if (ENEMY_TYPES[e.type].splits && !e.elite) {
|
if (ENEMY_TYPES[e.type].splits) {
|
||||||
Sfx.split(e);
|
Sfx.split(e);
|
||||||
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
|
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
|
||||||
const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false);
|
const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false);
|
||||||
@@ -458,8 +460,8 @@ function updateEnemies(dt) {
|
|||||||
e.x += px; e.y += py; o.x -= px; o.y -= py;
|
e.x += px; e.y += py; o.x -= px; o.y -= py;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.x = clamp(e.x, G.arena.x0 + e.r, G.arena.x1 - e.r);
|
const pos = resolveSolids(e.x, e.y, e.r);
|
||||||
e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
e.x = pos.x; e.y = pos.y;
|
||||||
|
|
||||||
// contact damage
|
// contact damage
|
||||||
if (e.hitT > 0) e.hitT -= dt;
|
if (e.hitT > 0) e.hitT -= dt;
|
||||||
@@ -522,6 +524,8 @@ function updateEnemyAI(e, dt) {
|
|||||||
const behind = p.aim + Math.PI, range = rand(90, 125);
|
const behind = p.aim + Math.PI, range = rand(90, 125);
|
||||||
e.x = clamp(p.x + Math.cos(behind) * range, G.arena.x0 + e.r, G.arena.x1 - e.r);
|
e.x = clamp(p.x + Math.cos(behind) * range, G.arena.x0 + e.r, G.arena.x1 - e.r);
|
||||||
e.y = clamp(p.y + Math.sin(behind) * range, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
e.y = clamp(p.y + Math.sin(behind) * range, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
||||||
|
const landed = resolveSolids(e.x, e.y, e.r);
|
||||||
|
e.x = landed.x; e.y = landed.y;
|
||||||
e.phased = false; e.alpha = 1; e.ambushState = 'lunge'; e.lungeT = 0.48;
|
e.phased = false; e.alpha = 1; e.ambushState = 'lunge'; e.lungeT = 0.48;
|
||||||
e.faceDir = angleTo(e.x, e.y, p.x, p.y); e.attackT = 0.48; e.actionT = 0;
|
e.faceDir = angleTo(e.x, e.y, p.x, p.y); e.attackT = 0.48; e.actionT = 0;
|
||||||
}
|
}
|
||||||
@@ -547,7 +551,7 @@ function updateEnemyAI(e, dt) {
|
|||||||
e.summonT -= dt;
|
e.summonT -= dt;
|
||||||
if (e.summonT <= 0) {
|
if (e.summonT <= 0) {
|
||||||
e.summonT = e.elite ? 3 : 4;
|
e.summonT = e.elite ? 3 : 4;
|
||||||
spawnBroodMinis(e, 2, 0.35);
|
spawnBroodMinis(e, e.elite ? irand(4, 6) : 2, 0.35);
|
||||||
e.attackT = 0.5; e.actionT = 0;
|
e.attackT = 0.5; e.actionT = 0;
|
||||||
}
|
}
|
||||||
} else if (t.ranged) {
|
} else if (t.ranged) {
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ function drawHUD(ctx) {
|
|||||||
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
||||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
||||||
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
|
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
|
||||||
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
|
|
||||||
ctx.fillText('×' + (G.pressure || 1).toFixed(2), W - 168, H - 46);
|
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
||||||
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
||||||
|
|||||||
+35
@@ -246,6 +246,11 @@ function update(dt) {
|
|||||||
updateParticles(dt);
|
updateParticles(dt);
|
||||||
drawToastsUpdate(dt);
|
drawToastsUpdate(dt);
|
||||||
break;
|
break;
|
||||||
|
case 'bossload':
|
||||||
|
updateParticles(dt);
|
||||||
|
if (G.bossLoadT > 0) G.bossLoadT -= dt;
|
||||||
|
if (G.bossLoadT <= 0 && Sprites.warm(G.bossLoadSheet)) G.mode = 'play';
|
||||||
|
break;
|
||||||
case 'levelup':
|
case 'levelup':
|
||||||
updateLevelup(dt);
|
updateLevelup(dt);
|
||||||
updateParticles(dt);
|
updateParticles(dt);
|
||||||
@@ -328,6 +333,7 @@ function draw() {
|
|||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
if (G.mode === 'menu') { drawMenu(ctx); return; }
|
if (G.mode === 'menu') { drawMenu(ctx); return; }
|
||||||
|
if (G.mode === 'bossload') { drawBossLoad(ctx); return; }
|
||||||
|
|
||||||
updateCamera();
|
updateCamera();
|
||||||
|
|
||||||
@@ -378,6 +384,35 @@ function draw() {
|
|||||||
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
|
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawBossLoad(ctx) {
|
||||||
|
if (G.cur) {
|
||||||
|
drawFloor(ctx, G.cur);
|
||||||
|
drawWalls(ctx, G.cur);
|
||||||
|
}
|
||||||
|
const sheet = G.bossLoadSheet && Sprites.get(G.bossLoadSheet);
|
||||||
|
if (sheet) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.drawImage(sheet, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = '#0a0506';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const name = G.boss && G.boss.name ? G.boss.name : 'FRESH MEAT';
|
||||||
|
ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 28px monospace';
|
||||||
|
ctx.fillText(name, W / 2, H / 2 - 6);
|
||||||
|
ctx.fillStyle = '#8f1f2e'; ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.fillText('FRESH MEAT APPROACHES', W / 2, H / 2 + 16);
|
||||||
|
const pr = Math.min(1, Math.max(0, G.bossLoadT / BOSS_LOAD_MIN));
|
||||||
|
const tier = Math.floor((1 - pr) * 5) % 3;
|
||||||
|
ctx.fillStyle = '#3a1a1e';
|
||||||
|
for (let i = 0; i < 3; i++) ctx.fillRect(W / 2 - 22 + i * 14, H / 2 + 30, 8, 8);
|
||||||
|
ctx.fillStyle = '#d7a934';
|
||||||
|
ctx.fillRect(W / 2 - 22 + tier * 14, H / 2 + 30, 8, 8);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
function drawMenu(ctx) {
|
function drawMenu(ctx) {
|
||||||
const t = performance.now() / 1000;
|
const t = performance.now() / 1000;
|
||||||
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
|
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
|
||||||
|
|||||||
+28
-2
@@ -104,12 +104,29 @@ function spawnShockwave(x, y, radius, color, alpha, important) {
|
|||||||
}, important);
|
}, important);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function spawnDashBurst(x, y, ang) {
|
||||||
|
addParticle({ type: 'bloodball', x, y, vx: 0, vy: 0, life: 0.18, t: 0, r: 10, maxR: 56 }, true);
|
||||||
|
spawnShockwave(x, y, 52, '#8b1020', 0.75);
|
||||||
|
spawnBlood(x, y, ang, 16, true);
|
||||||
|
spawnGibs(x, y, 7, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function spawnSweepFx(x, y, ang, reach, arc) {
|
function spawnSweepFx(x, y, ang, reach, arc) {
|
||||||
addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.18, t: 0 }, true);
|
addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.22, t: 0 }, true);
|
||||||
|
addParticle({ type: 'sweep', x, y, ang, arc: arc * 0.72, r: 12, maxR: reach * 0.82, life: 0.14, t: 0 }, true);
|
||||||
|
spawnShockwave(x, y, reach * 0.55, '#8b1020', 0.45);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const a = ang - arc / 2 + (i / 9) * arc;
|
||||||
|
const d = reach * rand(0.35, 1);
|
||||||
|
const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d;
|
||||||
|
spawnBlood(px, py, a, 2, i % 3 === 0);
|
||||||
|
if (i % 2 === 0) spawnSpark(px, py, a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnMuzzleFx(x, y, ang, behavior) {
|
function spawnMuzzleFx(x, y, ang, behavior) {
|
||||||
if (behavior === 'slam') return;
|
if (behavior === 'slam' || behavior === 'sweep') return;
|
||||||
const back = ang + Math.PI;
|
const back = ang + Math.PI;
|
||||||
if (behavior === 'flame') {
|
if (behavior === 'flame') {
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -249,6 +266,15 @@ function drawParticles(ctx) {
|
|||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
|
||||||
ctx.globalAlpha = k * 0.18;
|
ctx.globalAlpha = k * 0.18;
|
||||||
ctx.fillStyle = p.color; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, 0, 0, TAU); ctx.fill();
|
ctx.fillStyle = p.color; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, 0, 0, TAU); ctx.fill();
|
||||||
|
} else if (p.type === 'bloodball') {
|
||||||
|
const progress = 1 - k;
|
||||||
|
const radius = lerp(p.r, p.maxR, progress);
|
||||||
|
ctx.globalAlpha = k * 0.82;
|
||||||
|
ctx.fillStyle = '#6a0b14';
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.fill();
|
||||||
|
ctx.globalAlpha = k * 0.55;
|
||||||
|
ctx.fillStyle = '#b41828';
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.55, radius * 0.55 * 0.72, 0, 0, TAU); ctx.fill();
|
||||||
} else if (p.type === 'beam') {
|
} else if (p.type === 'beam') {
|
||||||
ctx.globalAlpha = k;
|
ctx.globalAlpha = k;
|
||||||
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
|
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
|
||||||
|
|||||||
+20
-11
@@ -87,11 +87,16 @@ function updatePlayer(dt) {
|
|||||||
const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1;
|
const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1;
|
||||||
if (keyPressed('shift') && p.dashSlowT <= 0) {
|
if (keyPressed('shift') && p.dashSlowT <= 0) {
|
||||||
const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T;
|
const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T;
|
||||||
p.x += (mx / len) * dashD;
|
const sx = p.x, sy = p.y;
|
||||||
p.y += (my / len) * dashD;
|
const stepD = dashD / 8;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
p.x += (mx / len) * stepD;
|
||||||
|
p.y += (my / len) * stepD;
|
||||||
|
const pos = resolveSolids(p.x, p.y, p.r);
|
||||||
|
p.x = pos.x; p.y = pos.y;
|
||||||
|
}
|
||||||
p.dashSlowT = DASH_SLOW_T;
|
p.dashSlowT = DASH_SLOW_T;
|
||||||
spawnSmoke(p.x, p.y, Math.atan2(-my, -mx), 6, '#9b8d84');
|
spawnDashBurst(sx, sy, Math.atan2(my, mx));
|
||||||
spawnShockwave(p.x, p.y, 26, '#d8c9a8', 0.35);
|
|
||||||
Sfx.duck();
|
Sfx.duck();
|
||||||
}
|
}
|
||||||
const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.5 : 1);
|
const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.5 : 1);
|
||||||
@@ -107,8 +112,8 @@ function updatePlayer(dt) {
|
|||||||
p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1));
|
p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1));
|
||||||
p.recoil = Math.max(0, p.recoil - dt * 7);
|
p.recoil = Math.max(0, p.recoil - dt * 7);
|
||||||
p.muzzleT = Math.max(0, p.muzzleT - dt);
|
p.muzzleT = Math.max(0, p.muzzleT - dt);
|
||||||
p.x = clamp(p.x, G.arena.x0 + p.r, G.arena.x1 - p.r);
|
const pos = resolveSolids(p.x, p.y, p.r);
|
||||||
p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r);
|
p.x = pos.x; p.y = pos.y;
|
||||||
|
|
||||||
// aim
|
// aim
|
||||||
p.aim = angleTo(p.x, p.y, mxW(), myW());
|
p.aim = angleTo(p.x, p.y, mxW(), myW());
|
||||||
@@ -439,18 +444,22 @@ function drawPlayer(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const w = WEAPONS[p.weapon.id];
|
const w = WEAPONS[p.weapon.id];
|
||||||
|
let swingFacing = p.aim;
|
||||||
|
if (w.id === 'cleaver' && p.attackT > 0) {
|
||||||
|
swingFacing = p.aim + (1 - clamp(p.attackT / 0.4, 0, 1)) * TAU * 2.5;
|
||||||
|
}
|
||||||
const kick = p.recoil * 3;
|
const kick = p.recoil * 3;
|
||||||
Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46);
|
Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46);
|
||||||
if (p.hp <= 0) {
|
if (p.hp <= 0) {
|
||||||
Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true);
|
Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true);
|
||||||
} else {
|
} else {
|
||||||
Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124);
|
Sprites.legs(ctx, p.x, p.y + 4, w.id === 'cleaver' && p.attackT > 0 ? swingFacing : p.bodyFacing, p.step, 124);
|
||||||
const torsoOffset = (w.torsoFwd || 18) - kick;
|
const torsoOffset = (w.torsoFwd || 18) - kick;
|
||||||
const torsoX = p.x + Math.cos(p.aim) * torsoOffset;
|
const torsoX = p.x + Math.cos(swingFacing) * torsoOffset;
|
||||||
const torsoY = p.y + Math.sin(p.aim) * torsoOffset;
|
const torsoY = p.y + Math.sin(swingFacing) * torsoOffset;
|
||||||
Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, p.aim, w.torsoW || 116, p.hitT > 0);
|
Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, swingFacing, w.torsoW || 116, p.hitT > 0);
|
||||||
}
|
}
|
||||||
if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw') {
|
if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw' && w.behavior !== 'sweep') {
|
||||||
const muzzleDist = (w.muzzle || 40) - kick;
|
const muzzleDist = (w.muzzle || 40) - kick;
|
||||||
let mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist;
|
let mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist;
|
||||||
if (w.behavior === 'flame') { const rh = p.aim + Math.PI / 2; mx += Math.cos(rh) * 26; my += Math.sin(rh) * 26; }
|
if (w.behavior === 'flame') { const rh = p.aim + Math.PI / 2; mx += Math.cos(rh) * 26; my += Math.sin(rh) * 26; }
|
||||||
|
|||||||
+66
-3
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
||||||
const OPP = { n: 's', s: 'n', e: 'w', w: 'e' };
|
const OPP = { n: 's', s: 'n', e: 'w', w: 'e' };
|
||||||
|
const OBSTACLE_COUNTS = { grand_hall: 3, deep_hall: 3, meat_hall: 5 };
|
||||||
|
const OBSTACLE_R = 42;
|
||||||
|
|
||||||
const ROOM_SHAPES = {
|
const ROOM_SHAPES = {
|
||||||
hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms
|
hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms
|
||||||
wide_hall: { x0: 48, y0: 176, x1: 912, y1: 464 }, // h 224 -> 288 ("a little wider"; cy stays 320, min dim 288 < 300 keeps censer/broodsac excluded)
|
wide_hall: { x0: 48, y0: 176, x1: 912, y1: 464 }, // h 224 -> 288 ("a little wider"; cy stays 320, min dim 288 < 300 keeps censer/broodsac excluded)
|
||||||
@@ -64,16 +67,67 @@ function genFloor(floor) {
|
|||||||
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
|
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
|
||||||
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
|
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
|
||||||
}
|
}
|
||||||
|
placeRoomObstacles(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRoom(gx, gy, type) {
|
function makeRoom(gx, gy, type) {
|
||||||
return { gx, gy, type, shape: 'hall', theme: 'abattoir', doors: {}, cleared: type === 'start',
|
return { gx, gy, type, shape: 'hall', theme: 'abattoir', doors: {}, cleared: type === 'start',
|
||||||
visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] };
|
visited: false, decals: [], obstacles: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomKey(x, y) { return x + ',' + y; }
|
function roomKey(x, y) { return x + ',' + y; }
|
||||||
|
|
||||||
|
function nearRoomDoor(room, x, y) {
|
||||||
|
const a = roomBounds(room);
|
||||||
|
const M = Math.min(130, Math.min(a.w, a.h) * 0.32);
|
||||||
|
if (room.doors.n && Math.abs(x - a.cx) < DOOR_HALF + 30 && y < a.y0 + M) return true;
|
||||||
|
if (room.doors.s && Math.abs(x - a.cx) < DOOR_HALF + 30 && y > a.y1 - M) return true;
|
||||||
|
if (room.doors.e && Math.abs(y - a.cy) < DOOR_HALF + 30 && x > a.x1 - M) return true;
|
||||||
|
if (room.doors.w && Math.abs(y - a.cy) < DOOR_HALF + 30 && x < a.x0 + M) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeRoomObstacles(room) {
|
||||||
|
room.obstacles = [];
|
||||||
|
const count = OBSTACLE_COUNTS[room.shape];
|
||||||
|
if (!count) return;
|
||||||
|
const a = roomBounds(room);
|
||||||
|
for (let tries = 0; tries < 80 && room.obstacles.length < count; tries++) {
|
||||||
|
const x = rand(a.x0 + 80, a.x1 - 80);
|
||||||
|
const y = rand(a.y0 + 80, a.y1 - 80);
|
||||||
|
if (nearRoomDoor(room, x, y)) continue;
|
||||||
|
if (dist(x, y, a.cx, a.cy) < 110) continue;
|
||||||
|
if (room.obstacles.some(o => dist(x, y, o.x, o.y) < OBSTACLE_R * 2 + 24)) continue;
|
||||||
|
room.obstacles.push({ x, y, r: OBSTACLE_R, kind: 'bloodpool' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSolids(x, y, r) {
|
||||||
|
const obstacles = G.cur && G.cur.obstacles;
|
||||||
|
if (obstacles && obstacles.length) {
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
for (const o of obstacles) {
|
||||||
|
const d = dist(x, y, o.x, o.y);
|
||||||
|
if (d < r + o.r) {
|
||||||
|
if (d > 0.01) {
|
||||||
|
const n = (r + o.r) / d;
|
||||||
|
x = o.x + (x - o.x) * n;
|
||||||
|
y = o.y + (y - o.y) * n;
|
||||||
|
} else {
|
||||||
|
x = o.x + r + o.r;
|
||||||
|
y = o.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x = clamp(x, G.arena.x0 + r, G.arena.x1 - r);
|
||||||
|
y = clamp(y, G.arena.y0 + r, G.arena.y1 - r);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function wavesFor(floor) {
|
function wavesFor(floor) {
|
||||||
const base = 3 + Math.ceil(floor * 1.4);
|
const base = 3 + Math.ceil(floor * 1.4);
|
||||||
const waves = [base];
|
const waves = [base];
|
||||||
@@ -110,8 +164,9 @@ function enterRoom(gx, gy) {
|
|||||||
// spawn protection: brief invincibility walking into a fresh room
|
// spawn protection: brief invincibility walking into a fresh room
|
||||||
if (G.player) {
|
if (G.player) {
|
||||||
G.player.invT = Math.max(G.player.invT, 1.0);
|
G.player.invT = Math.max(G.player.invT, 1.0);
|
||||||
G.player.x = clamp(G.player.x, a.x0 + G.player.r, a.x1 - G.player.r);
|
const pos = resolveSolids(G.player.x, G.player.y, G.player.r);
|
||||||
G.player.y = clamp(G.player.y, a.y0 + G.player.r, a.y1 - G.player.r);
|
G.player.x = pos.x;
|
||||||
|
G.player.y = pos.y;
|
||||||
}
|
}
|
||||||
// Iron Lung: re-arm the once-per-room hit block
|
// Iron Lung: re-arm the once-per-room hit block
|
||||||
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
|
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
|
||||||
@@ -132,6 +187,10 @@ function enterRoom(gx, gy) {
|
|||||||
// boss fights are a pure ammo drain, so stock the room on the way in
|
// boss fights are a pure ammo drain, so stock the room on the way in
|
||||||
spawnPickup('ammo', a.cx, a.cy + 140);
|
spawnPickup('ammo', a.cx, a.cy + 140);
|
||||||
Music.playBoss(boss.bossKind);
|
Music.playBoss(boss.bossKind);
|
||||||
|
G.mode = 'bossload';
|
||||||
|
G.bossLoadSheet = boss.sprite + '_sheet';
|
||||||
|
G.bossLoadT = BOSS_LOAD_MIN;
|
||||||
|
Sprites.warm(G.bossLoadSheet);
|
||||||
} else if (room.type === 'item') {
|
} else if (room.type === 'item') {
|
||||||
spawnItemPedestal(a.cx - 45, a.cy, null, 'room');
|
spawnItemPedestal(a.cx - 45, a.cy, null, 'room');
|
||||||
// item rooms sometimes also stock a weapon
|
// item rooms sometimes also stock a weapon
|
||||||
@@ -420,6 +479,10 @@ function drawRoom(ctx) {
|
|||||||
for (const d of r.decals) {
|
for (const d of r.decals) {
|
||||||
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
|
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
|
||||||
}
|
}
|
||||||
|
for (const o of r.obstacles) {
|
||||||
|
Sprites.draw(ctx, 'decal_blood1', o.x, o.y, 0, o.r * 2.4, false, 0.92);
|
||||||
|
Sprites.draw(ctx, 'decal_blood3', o.x, o.y, 0.7, o.r * 1.7, false, 0.75);
|
||||||
|
}
|
||||||
// hazards
|
// hazards
|
||||||
for (const h of G.hazards) {
|
for (const h of G.hazards) {
|
||||||
const k = 1 - h.t / h.life;
|
const k = 1 - h.t / h.life;
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ const SfxBank = {
|
|||||||
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
||||||
const file = this.FILES[name];
|
const file = this.FILES[name];
|
||||||
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
||||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=59')
|
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=61')
|
||||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||||
.then(bytes => this.ctx.decodeAudioData(bytes))
|
.then(bytes => this.ctx.decodeAudioData(bytes))
|
||||||
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
||||||
|
|||||||
+19
-2
@@ -88,15 +88,32 @@ const Sprites = {
|
|||||||
const finish = () => { if (--pending === 0) G.imagesLoaded = true; };
|
const finish = () => { if (--pending === 0) G.imagesLoaded = true; };
|
||||||
img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); };
|
img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); };
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=59'; return; }
|
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=61'; return; }
|
||||||
|
this.imgs[name] = null;
|
||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
img.src = hd ? 'assets/hd/' + name + '.webp?v=59' : 'assets/' + name + '.png?v=59';
|
img.src = hd ? 'assets/hd/' + name + '.webp?v=61' : 'assets/' + name + '.png?v=61';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get(name) { return this.imgs[name] || null; },
|
get(name) { return this.imgs[name] || null; },
|
||||||
|
|
||||||
|
forceUpload(img) {
|
||||||
|
if (typeof document === 'undefined' || !document.createElement) return;
|
||||||
|
if (!document.createElement('canvas').getContext) return;
|
||||||
|
if (!this._warmCtx) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 1; c.height = 1;
|
||||||
|
this._warmCtx = c.getContext('2d');
|
||||||
|
}
|
||||||
|
if (this._warmCtx) this._warmCtx.drawImage(img, 0, 0, 1, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
warm(name) {
|
||||||
|
if (this.imgs[name]) { this.forceUpload(this.imgs[name]); return true; }
|
||||||
|
return this.imgs[name] !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
// Compact player-only strip: eight forward-facing stride frames.
|
// Compact player-only strip: eight forward-facing stride frames.
|
||||||
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
|
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
|
||||||
legs(ctx, x, y, facing, phase, targetW) {
|
legs(ctx, x, y, facing, phase, targetW) {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const G = {
|
|||||||
hudAlpha: 1,
|
hudAlpha: 1,
|
||||||
hdRemaster: false,
|
hdRemaster: false,
|
||||||
imagesLoaded: false,
|
imagesLoaded: false,
|
||||||
|
bossLoadT: 0,
|
||||||
|
bossLoadSheet: null,
|
||||||
roomLayer: null,
|
roomLayer: null,
|
||||||
roomLayerKey: '',
|
roomLayerKey: '',
|
||||||
atmosphereLayer: null,
|
atmosphereLayer: null,
|
||||||
@@ -72,6 +74,7 @@ const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
|
|||||||
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
||||||
const PRESSURE_RELIEF_MUL = 0.5; // global brake: all pressure drops (hit relief + passive decay) are halved
|
const PRESSURE_RELIEF_MUL = 0.5; // global brake: all pressure drops (hit relief + passive decay) are halved
|
||||||
const DEATH_LOCK = 3;
|
const DEATH_LOCK = 3;
|
||||||
|
const BOSS_LOAD_MIN = 1.0;
|
||||||
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
|
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
|
||||||
|
|
||||||
function hdScale() { return G.hdRemaster ? 4 : 1; }
|
function hdScale() { return G.hdRemaster ? 4 : 1; }
|
||||||
|
|||||||
+5
-2
@@ -11,7 +11,7 @@ const WEAPONS = {
|
|||||||
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
|
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
|
||||||
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
|
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
|
||||||
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
|
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
|
||||||
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 34, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 90, sweepArc: 2.4, desc: 'A wide, dramatic melee sweep' },
|
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 42, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 110, sweepArc: 2.85, desc: 'A wider, gory whirl of steel' },
|
||||||
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 24, refill: 9, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
|
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 24, refill: 9, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
|
||||||
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 80, refill: 21, drain: 8, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
|
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 80, refill: 21, drain: 8, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
|
||||||
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 70, refill: 42, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
|
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 70, refill: 42, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
|
||||||
@@ -141,9 +141,12 @@ function fireWeapon(p, w) {
|
|||||||
const a = angleTo(p.x, p.y, e.x, e.y);
|
const a = angleTo(p.x, p.y, e.x, e.y);
|
||||||
if (Math.abs(angleDelta(a, ang)) > half) continue;
|
if (Math.abs(angleDelta(a, ang)) > half) continue;
|
||||||
damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' });
|
damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' });
|
||||||
|
spawnBlood(e.x, e.y, a, 8, true);
|
||||||
|
spawnGibs(e.x, e.y, 3);
|
||||||
|
addShake(2);
|
||||||
}
|
}
|
||||||
spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4);
|
spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4);
|
||||||
addShake(4);
|
addShake(6);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: { // bullet, bounce and homing projectiles
|
default: { // bullet, bounce and homing projectiles
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meatslicer",
|
"name": "meatslicer",
|
||||||
"productName": "MeatSlicer",
|
"productName": "MeatSlicer",
|
||||||
"version": "0.9.3",
|
"version": "0.9.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A Butcher's Descent - clear, harvest, descend.",
|
"description": "A Butcher's Descent - clear, harvest, descend.",
|
||||||
"main": "desktop/main.js",
|
"main": "desktop/main.js",
|
||||||
|
|||||||
+82
-4
@@ -64,7 +64,7 @@ for (const f of files) {
|
|||||||
}
|
}
|
||||||
// const/let top-level bindings live in the context's lexical scope, not on the
|
// const/let top-level bindings live in the context's lexical scope, not on the
|
||||||
// global object — expose the ones the harness pokes at directly.
|
// global object — expose the ones the harness pokes at directly.
|
||||||
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, PRESSURE_DROP_BASE, PRESSURE_RELIEF_MUL, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK })', sandbox);
|
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, PRESSURE_DROP_BASE, PRESSURE_RELIEF_MUL, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK, placeRoomObstacles, resolveSolids, spawnDashBurst })', sandbox);
|
||||||
|
|
||||||
let simTime = 0;
|
let simTime = 0;
|
||||||
const ctx = sandbox;
|
const ctx = sandbox;
|
||||||
@@ -84,6 +84,8 @@ function fire(ev, key) {
|
|||||||
function press(key) { fire('keydown', key); }
|
function press(key) { fire('keydown', key); }
|
||||||
function release(key) { fire('keyup', key); }
|
function release(key) { fire('keyup', key); }
|
||||||
function tap(key) { press(key); step(1, 16); release(key); }
|
function tap(key) { press(key); step(1, 16); release(key); }
|
||||||
|
function drainBossLoad() { let g = 0; while (ctx.G.mode === 'bossload' && g++ < 150) step(1, 16); }
|
||||||
|
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
function check(name, cond) {
|
function check(name, cond) {
|
||||||
@@ -216,7 +218,7 @@ check('HD loader loads all images (fallbacks)', ctx.G.imagesLoaded === true);
|
|||||||
let hdFallbackOk = 0;
|
let hdFallbackOk = 0;
|
||||||
for (const name of ctx.SPRITE_MANIFEST) {
|
for (const name of ctx.SPRITE_MANIFEST) {
|
||||||
const urls = attemptedUrls[name];
|
const urls = attemptedUrls[name];
|
||||||
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=59' && urls[1] === 'assets/' + name + '.png?v=59';
|
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=61' && urls[1] === 'assets/' + name + '.png?v=61';
|
||||||
if (ok) hdFallbackOk++;
|
if (ok) hdFallbackOk++;
|
||||||
}
|
}
|
||||||
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
|
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
|
||||||
@@ -241,7 +243,7 @@ check('SD loader loads all images', ctx.G.imagesLoaded === true);
|
|||||||
let sdUrlOk = 0;
|
let sdUrlOk = 0;
|
||||||
for (const name of ctx.SPRITE_MANIFEST) {
|
for (const name of ctx.SPRITE_MANIFEST) {
|
||||||
const urls = attemptedUrls[name];
|
const urls = attemptedUrls[name];
|
||||||
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=59';
|
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=61';
|
||||||
if (ok) sdUrlOk++;
|
if (ok) sdUrlOk++;
|
||||||
}
|
}
|
||||||
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
|
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
|
||||||
@@ -765,6 +767,28 @@ check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >=
|
|||||||
check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36);
|
check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36);
|
||||||
check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7);
|
check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7);
|
||||||
|
|
||||||
|
console.log('== cleaver cadence ==');
|
||||||
|
{
|
||||||
|
const c = ctx.WEAPONS.cleaver;
|
||||||
|
check('cleaver magazine unchanged', c.ammo === 26 && c.refill === 10);
|
||||||
|
check('cleaver sweep is larger and hits harder', c.dmg === 42 && c.sweepRange === 110 && c.sweepArc === 2.85);
|
||||||
|
const p = ctx.G.player;
|
||||||
|
p.weapon = { id: 'cleaver', ammo: 26 };
|
||||||
|
p.holstered = null;
|
||||||
|
p.fireT = 0; p.attackT = 0; p.aim = 0;
|
||||||
|
ctx.G.enemies.length = 0; ctx.G.parts.length = 0;
|
||||||
|
const e = ctx.makeEnemy('shambler', p.x + 50, p.y, 1, false);
|
||||||
|
e.spd = 0; e.hp = e.maxHp = 200;
|
||||||
|
ctx.G.enemies.push(e);
|
||||||
|
ctx.Input.mx = e.x; ctx.Input.my = e.y;
|
||||||
|
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
|
||||||
|
check('cleaver swing damages in the new reach', e.hp < e.maxHp);
|
||||||
|
check('cleaver swing starts the spin window', p.attackT > 0);
|
||||||
|
check('cleaver swing emits sweep VFX', ctx.G.parts.some(pt => pt.type === 'sweep'));
|
||||||
|
ctx.G.enemies.length = 0; ctx.G.parts.length = 0;
|
||||||
|
p.weapon = { id: 'bonepopper', ammo: Infinity };
|
||||||
|
}
|
||||||
|
|
||||||
console.log('== continuous saw frame-rate independence ==');
|
console.log('== continuous saw frame-rate independence ==');
|
||||||
{
|
{
|
||||||
const p = ctx.G.player;
|
const p = ctx.G.player;
|
||||||
@@ -1277,6 +1301,47 @@ console.log('== dash: burst, slow, cooldown ==');
|
|||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('== large-room blood pools and dash gore ==');
|
||||||
|
{
|
||||||
|
ctx.startRun(); step(3, 16);
|
||||||
|
const hall = ctx.makeRoom(0, 0, 'combat'); hall.shape = 'hall'; hall.doors = {};
|
||||||
|
ctx.placeRoomObstacles(hall);
|
||||||
|
check('small rooms get no obstacles', hall.obstacles.length === 0);
|
||||||
|
|
||||||
|
const meat = ctx.makeRoom(1, 0, 'combat'); meat.shape = 'meat_hall';
|
||||||
|
meat.doors = { e: true, w: true, n: false, s: false };
|
||||||
|
ctx.placeRoomObstacles(meat);
|
||||||
|
check('meat_hall places five pools', meat.obstacles.length === 5);
|
||||||
|
const mb = ctx.roomBounds(meat);
|
||||||
|
check('pools stay off center and doors', meat.obstacles.every(o =>
|
||||||
|
Math.hypot(o.x - mb.cx, o.y - mb.cy) >= 110));
|
||||||
|
|
||||||
|
ctx.G.cur = meat; ctx.setArenaForRoom(meat);
|
||||||
|
const o = meat.obstacles[0];
|
||||||
|
const pushed = ctx.resolveSolids(o.x, o.y, 20);
|
||||||
|
check('resolveSolids pushes out of a pool', Math.hypot(pushed.x - o.x, pushed.y - o.y) >= o.r + 20 - 0.05);
|
||||||
|
|
||||||
|
const p = ctx.G.player;
|
||||||
|
p.x = o.x - (o.r + p.r + 8); p.y = o.y; p.dashSlowT = 0;
|
||||||
|
ctx.G.parts.length = 0;
|
||||||
|
ctx.Input.keys.d = true;
|
||||||
|
press('shift'); step(1, 16); release('shift');
|
||||||
|
check('dash does not enter the pool', Math.hypot(p.x - o.x, p.y - o.y) >= o.r + p.r - 0.05);
|
||||||
|
check('dash emits a bloodball', ctx.G.parts.some(pt => pt.type === 'bloodball'));
|
||||||
|
ctx.Input.keys.d = false;
|
||||||
|
|
||||||
|
ctx.G.bullets.length = 0;
|
||||||
|
ctx.G.bullets.push({
|
||||||
|
x: o.x - 80, y: o.y, vx: 400, vy: 0, r: 5, dmg: 1,
|
||||||
|
pierce: 0, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet',
|
||||||
|
sprite: 'bullet_bone', homing: 0, hit: null, dragTarget: null,
|
||||||
|
});
|
||||||
|
ctx.updateBullets(0.3);
|
||||||
|
check('projectiles pass through pools', ctx.G.bullets.length === 1 && ctx.G.bullets[0].x > o.x);
|
||||||
|
|
||||||
|
ctx.startRun(); step(3, 16);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('== debug refill tops up holstered specials ==');
|
console.log('== debug refill tops up holstered specials ==');
|
||||||
{
|
{
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
@@ -1577,6 +1642,13 @@ console.log('== 750ms entry-wave spawn telegraph ==');
|
|||||||
check('splitter splits into minis', minis.length === 2);
|
check('splitter splits into minis', minis.length === 2);
|
||||||
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
||||||
ctx.G.enemies.length = 0;
|
ctx.G.enemies.length = 0;
|
||||||
|
const eliteSplit = ctx.makeEnemy('splitter', ctx.W / 2, ctx.H / 2, 1, true);
|
||||||
|
ctx.G.enemies.push(eliteSplit);
|
||||||
|
ctx.damageEnemy(eliteSplit, 99999, 0, false);
|
||||||
|
const eliteMinis = ctx.G.enemies.filter(en => en.type === 'mini');
|
||||||
|
check('elite splitter splits into minis', eliteSplit.elite === true && eliteMinis.length === 2);
|
||||||
|
check('elite splits spawn instantly', eliteMinis.every(m => (m.warmT || 0) === 0));
|
||||||
|
ctx.G.enemies.length = 0;
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1905,7 +1977,12 @@ check('floor 2', ctx.G.floor === 2);
|
|||||||
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||||
check('boss room exists', !!bossRoom);
|
check('boss room exists', !!bossRoom);
|
||||||
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
|
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
|
||||||
step(10, 16);
|
step(1, 16);
|
||||||
|
check('boss room opens a loading screen', ctx.G.mode === 'bossload');
|
||||||
|
drainBossLoad();
|
||||||
|
check('boss loading screen resolves to play', ctx.G.mode === 'play');
|
||||||
|
check('boss sheet warm helper exists', typeof ctx.Sprites.warm === 'function');
|
||||||
|
check('elite summoner count uses 4-6', (() => { const e = ctx.makeEnemy('broodsac', 400, 300, 3, true); return e.elite === true; })());
|
||||||
check('boss spawned', !!ctx.G.boss);
|
check('boss spawned', !!ctx.G.boss);
|
||||||
check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp));
|
check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp));
|
||||||
check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss'));
|
check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss'));
|
||||||
@@ -2214,6 +2291,7 @@ ctx.nextFloor(); step(5, 16);
|
|||||||
check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
|
check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
|
||||||
const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||||
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
|
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
|
||||||
|
drainBossLoad();
|
||||||
check('boss track interrupts override', ctx.Music.current.name.includes('Boss'));
|
check('boss track interrupts override', ctx.Music.current.name.includes('Boss'));
|
||||||
ctx.Music.cycle(1); // cycling during a boss must not cut the boss track
|
ctx.Music.cycle(1); // cycling during a boss must not cut the boss track
|
||||||
check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));
|
check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ def reference_payload(name: str) -> list[dict]:
|
|||||||
if not REFERENCE.exists():
|
if not REFERENCE.exists():
|
||||||
raise SystemExit(f"Missing style reference: {REFERENCE}")
|
raise SystemExit(f"Missing style reference: {REFERENCE}")
|
||||||
references = [{"type": "image_url", "image_url": {"url": data_url(REFERENCE)}}]
|
references = [{"type": "image_url", "image_url": {"url": data_url(REFERENCE)}}]
|
||||||
|
if name.startswith(("w_", "wt_", "pt_")):
|
||||||
|
mid = name[3:] if name.startswith(("wt_", "pt_")) else name[2:]
|
||||||
|
prefix = "wt_" if name.startswith(("wt_", "pt_")) else "w_"
|
||||||
|
model = RAW / "guides" / "models" / f"{prefix}{mid}.png"
|
||||||
|
if model.exists():
|
||||||
|
references.append({"type": "image_url", "image_url": {"url": data_url(model)}})
|
||||||
guide = shape_guide(name)
|
guide = shape_guide(name)
|
||||||
if guide:
|
if guide:
|
||||||
references.append({"type": "image_url", "image_url": {"url": data_url(guide)}})
|
references.append({"type": "image_url", "image_url": {"url": data_url(guide)}})
|
||||||
|
|||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render Meshy weapon GLBs to 2D magenta-backed stills via Blender."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BLENDER_SCRIPT = Path(__file__).resolve().parent / "render_model_refs_blender.py"
|
||||||
|
IN_DIR = ROOT / "assets" / "raw" / "models"
|
||||||
|
OUT_DIR = ROOT / "assets" / "raw" / "guides" / "models"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
blender = shutil.which("blender")
|
||||||
|
if not blender:
|
||||||
|
print("blender: missing binary; skipping model reference renders")
|
||||||
|
return 0
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
blender,
|
||||||
|
"--background",
|
||||||
|
"--python",
|
||||||
|
str(BLENDER_SCRIPT),
|
||||||
|
"--",
|
||||||
|
"--in",
|
||||||
|
str(IN_DIR),
|
||||||
|
"--out",
|
||||||
|
str(OUT_DIR),
|
||||||
|
]
|
||||||
|
print(" ".join(cmd))
|
||||||
|
return subprocess.call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Blender 5.2: orthographic stills of each weapon_<id>.glb."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
|
||||||
|
MAGENTA = (1.0, 0.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--in", dest="in_dir", required=True)
|
||||||
|
parser.add_argument("--out", dest="out_dir", required=True)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_scene():
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
scene = bpy.context.scene
|
||||||
|
scene.render.engine = "BLENDER_EEVEE"
|
||||||
|
scene.render.resolution_x = 1024
|
||||||
|
scene.render.resolution_y = 1024
|
||||||
|
scene.render.film_transparent = False
|
||||||
|
scene.render.image_settings.file_format = "PNG"
|
||||||
|
scene.render.image_settings.color_mode = "RGBA"
|
||||||
|
world = bpy.data.worlds.new("Magenta")
|
||||||
|
scene.world = world
|
||||||
|
world.use_nodes = True
|
||||||
|
bg = world.node_tree.nodes.get("Background")
|
||||||
|
if bg:
|
||||||
|
bg.inputs[0].default_value = MAGENTA
|
||||||
|
bg.inputs[1].default_value = 1.0
|
||||||
|
light_data = bpy.data.lights.new("Sun", "SUN")
|
||||||
|
light_data.energy = 5.0
|
||||||
|
light = bpy.data.objects.new("Sun", light_data)
|
||||||
|
scene.collection.objects.link(light)
|
||||||
|
light.location = (-1.0, -1.0, 2.0)
|
||||||
|
light.rotation_euler = (math.atan2(math.hypot(1, 1), 2), 0.0, math.atan2(-1, -1))
|
||||||
|
cam_data = bpy.data.cameras.new("Cam")
|
||||||
|
cam_data.type = "ORTHO"
|
||||||
|
cam_data.ortho_scale = 2.4
|
||||||
|
cam = bpy.data.objects.new("Cam", cam_data)
|
||||||
|
scene.collection.objects.link(cam)
|
||||||
|
scene.camera = cam
|
||||||
|
return scene, cam
|
||||||
|
|
||||||
|
|
||||||
|
def import_weapon(path: Path):
|
||||||
|
before = set(bpy.data.objects)
|
||||||
|
bpy.ops.import_scene.gltf(filepath=str(path))
|
||||||
|
imported = [obj for obj in bpy.data.objects if obj not in before]
|
||||||
|
meshes = [obj for obj in imported if obj.type == "MESH"]
|
||||||
|
if not meshes:
|
||||||
|
return None
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
for obj in meshes:
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = meshes[0]
|
||||||
|
if len(meshes) > 1:
|
||||||
|
bpy.ops.object.join()
|
||||||
|
joined = bpy.context.view_layer.objects.active
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_weapon(obj):
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||||
|
min_c = Vector((min(v.x for v in bbox), min(v.y for v in bbox), min(v.z for v in bbox)))
|
||||||
|
max_c = Vector((max(v.x for v in bbox), max(v.y for v in bbox), max(v.z for v in bbox)))
|
||||||
|
size = max_c - min_c
|
||||||
|
longest = max(size.x, size.y, size.z, 1e-6)
|
||||||
|
scale = 2.0 / longest
|
||||||
|
obj.scale *= scale
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||||
|
center = sum(bbox, Vector()) / 8.0
|
||||||
|
obj.location -= center
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||||
|
min_c = Vector((min(v.x for v in bbox), min(v.y for v in bbox), min(v.z for v in bbox)))
|
||||||
|
max_c = Vector((max(v.x for v in bbox), max(v.y for v in bbox), max(v.z for v in bbox)))
|
||||||
|
size = max_c - min_c
|
||||||
|
axis = max(((size.x, "x"), (size.y, "y"), (size.z, "z")))[1]
|
||||||
|
if axis == "y":
|
||||||
|
obj.rotation_euler[2] += -math.pi / 2
|
||||||
|
elif axis == "z":
|
||||||
|
obj.rotation_euler[1] += math.pi / 2
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||||
|
center = sum(bbox, Vector()) / 8.0
|
||||||
|
obj.location -= center
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
|
||||||
|
|
||||||
|
def aim_camera(cam, location):
|
||||||
|
cam.location = location
|
||||||
|
direction = Vector((0.0, 0.0, 0.0)) - Vector(location)
|
||||||
|
cam.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||||
|
|
||||||
|
|
||||||
|
def render_to(scene, path: Path):
|
||||||
|
scene.render.filepath = str(path)
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
args = parse_args(argv)
|
||||||
|
in_dir = Path(args.in_dir)
|
||||||
|
out_dir = Path(args.out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
glbs = sorted(in_dir.glob("weapon_*.glb"))
|
||||||
|
for glb in glbs:
|
||||||
|
wid = glb.stem[len("weapon_") :]
|
||||||
|
scene, cam = reset_scene()
|
||||||
|
obj = import_weapon(glb)
|
||||||
|
if obj is None:
|
||||||
|
print(f"skip {glb.name}: no mesh")
|
||||||
|
continue
|
||||||
|
normalize_weapon(obj)
|
||||||
|
aim_camera(cam, (0.0, 0.0, 4.0))
|
||||||
|
render_to(scene, out_dir / f"wt_{wid}.png")
|
||||||
|
aim_camera(cam, (0.0, -3.2, 1.4))
|
||||||
|
render_to(scene, out_dir / f"w_{wid}.png")
|
||||||
|
print(f"rendered {wid}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
argv = sys.argv
|
||||||
|
if "--" in argv:
|
||||||
|
argv = argv[argv.index("--") + 1 :]
|
||||||
|
else:
|
||||||
|
argv = []
|
||||||
|
main(argv)
|
||||||
Reference in new issue
Block a user