diff --git a/js/enemies.js b/js/enemies.js index 2552642..b8d76ef 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -100,6 +100,8 @@ function spawnPosAwayFromPlayer() { const y = rand(a.y0 + 40, a.y1 - 40); if (dist(x, y, p.x, p.y) <= safeDist) 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 }; } // fallback: a bounded central region, valid even in the thin hall variants @@ -458,8 +460,8 @@ function updateEnemies(dt) { 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); - e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r); + const pos = resolveSolids(e.x, e.y, e.r); + e.x = pos.x; e.y = pos.y; // contact damage 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); 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); + 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.faceDir = angleTo(e.x, e.y, p.x, p.y); e.attackT = 0.48; e.actionT = 0; } diff --git a/js/particles.js b/js/particles.js index de0d867..7236703 100644 --- a/js/particles.js +++ b/js/particles.js @@ -104,6 +104,14 @@ function spawnShockwave(x, y, radius, color, alpha, 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) { addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.18, t: 0 }, true); } @@ -249,6 +257,15 @@ function drawParticles(ctx) { ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke(); 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(); + } 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') { ctx.globalAlpha = k; ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3; diff --git a/js/player.js b/js/player.js index cbd25b0..deb1517 100644 --- a/js/player.js +++ b/js/player.js @@ -87,11 +87,16 @@ function updatePlayer(dt) { const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1; if (keyPressed('shift') && p.dashSlowT <= 0) { const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T; - p.x += (mx / len) * dashD; - p.y += (my / len) * dashD; + const sx = p.x, sy = p.y; + 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; - spawnSmoke(p.x, p.y, Math.atan2(-my, -mx), 6, '#9b8d84'); - spawnShockwave(p.x, p.y, 26, '#d8c9a8', 0.35); + spawnDashBurst(sx, sy, Math.atan2(my, mx)); Sfx.duck(); } 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.recoil = Math.max(0, p.recoil - dt * 7); p.muzzleT = Math.max(0, p.muzzleT - dt); - p.x = clamp(p.x, G.arena.x0 + p.r, G.arena.x1 - p.r); - p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r); + const pos = resolveSolids(p.x, p.y, p.r); + p.x = pos.x; p.y = pos.y; // aim p.aim = angleTo(p.x, p.y, mxW(), myW()); diff --git a/js/rooms.js b/js/rooms.js index 1e72ed9..904b202 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -2,6 +2,9 @@ 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 OBSTACLE_COUNTS = { grand_hall: 3, deep_hall: 3, meat_hall: 5 }; +const OBSTACLE_R = 42; + const ROOM_SHAPES = { 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) @@ -64,16 +67,67 @@ function genFloor(floor) { for (const [dir, [dx, dy]] of Object.entries(DIRS)) { r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)]; } + placeRoomObstacles(r); } } function makeRoom(gx, gy, type) { 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 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) { const base = 3 + Math.ceil(floor * 1.4); const waves = [base]; @@ -110,8 +164,9 @@ function enterRoom(gx, gy) { // spawn protection: brief invincibility walking into a fresh room if (G.player) { 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); - G.player.y = clamp(G.player.y, a.y0 + G.player.r, a.y1 - G.player.r); + const pos = resolveSolids(G.player.x, G.player.y, G.player.r); + G.player.x = pos.x; + G.player.y = pos.y; } // Iron Lung: re-arm the once-per-room hit block if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true; @@ -420,6 +475,10 @@ function drawRoom(ctx) { for (const d of r.decals) { 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 for (const h of G.hazards) { const k = 1 - h.t / h.life; diff --git a/package.json b/package.json index d7ac8f9..cc019e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.9.3", + "version": "0.9.4", "private": true, "description": "A Butcher's Descent - clear, harvest, descend.", "main": "desktop/main.js", diff --git a/test/smoke.js b/test/smoke.js index de2a3ba..3f20ed5 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -64,7 +64,7 @@ for (const f of files) { } // 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. -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; const ctx = sandbox; @@ -1277,6 +1277,47 @@ console.log('== dash: burst, slow, cooldown =='); 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 =='); { ctx.startRun(); step(3, 16); diff --git a/tools/gen_assets.py b/tools/gen_assets.py index 62c64e4..cecf10f 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -390,6 +390,12 @@ def reference_payload(name: str) -> list[dict]: if not REFERENCE.exists(): raise SystemExit(f"Missing style reference: {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) if guide: references.append({"type": "image_url", "image_url": {"url": data_url(guide)}}) diff --git a/tools/render_model_refs.py b/tools/render_model_refs.py new file mode 100755 index 0000000..1016f70 --- /dev/null +++ b/tools/render_model_refs.py @@ -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()) diff --git a/tools/render_model_refs_blender.py b/tools/render_model_refs_blender.py new file mode 100755 index 0000000..7d9dd67 --- /dev/null +++ b/tools/render_model_refs_blender.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Blender 5.2: orthographic stills of each weapon_.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)