2 Commits
13 changed files with 132 additions and 49 deletions

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@ This guide consolidates the durable project knowledge used to develop, test,
package, and maintain MeatSlicer. When this guide and the implementation
disagree, the implementation and configuration files are the source of truth.
**Memory version: 0.5.2**
**Memory version: 0.6.0**
## Architecture
@@ -201,8 +201,8 @@ failures, and an SFX buffer count matching `SfxBank.FILES` and the files on
disk; the exact count can change as audio is added.
The Windows build is not code-signed, so downloaded copies may trigger a
SmartScreen warning. Code signing, installers, automatic updates, and
macOS/Linux packaging remain future work.
SmartScreen warning. Code signing, installers, and automatic updates remain
future work.
## Visual asset pipeline
+22 -22
View File
@@ -6,32 +6,32 @@
<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>">
<title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=51">
<link rel="stylesheet" href="css/style.css?v=52">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=51"></script>
<script src="js/state.js?v=51"></script>
<script src="js/input.js?v=51"></script>
<script src="js/sfx.js?v=51"></script>
<script src="js/sfxbank.js?v=51"></script>
<script src="js/audio.js?v=51"></script>
<script src="js/sprites.js?v=51"></script>
<script src="js/particles.js?v=51"></script>
<script src="js/weapons.js?v=51"></script>
<script src="js/bullets.js?v=51"></script>
<script src="js/pickups.js?v=51"></script>
<script src="js/items.js?v=51"></script>
<script src="js/perks.js?v=51"></script>
<script src="js/enemies.js?v=51"></script>
<script src="js/bosses.js?v=51"></script>
<script src="js/rooms.js?v=51"></script>
<script src="js/player.js?v=51"></script>
<script src="js/hud.js?v=51"></script>
<script src="js/help.js?v=51"></script>
<script src="js/debug.js?v=51"></script>
<script src="js/main.js?v=51"></script>
<script src="js/utils.js?v=52"></script>
<script src="js/state.js?v=52"></script>
<script src="js/input.js?v=52"></script>
<script src="js/sfx.js?v=52"></script>
<script src="js/sfxbank.js?v=52"></script>
<script src="js/audio.js?v=52"></script>
<script src="js/sprites.js?v=52"></script>
<script src="js/particles.js?v=52"></script>
<script src="js/weapons.js?v=52"></script>
<script src="js/bullets.js?v=52"></script>
<script src="js/pickups.js?v=52"></script>
<script src="js/items.js?v=52"></script>
<script src="js/perks.js?v=52"></script>
<script src="js/enemies.js?v=52"></script>
<script src="js/bosses.js?v=52"></script>
<script src="js/rooms.js?v=52"></script>
<script src="js/player.js?v=52"></script>
<script src="js/hud.js?v=52"></script>
<script src="js/help.js?v=52"></script>
<script src="js/debug.js?v=52"></script>
<script src="js/main.js?v=52"></script>
</body>
</html>
+2 -2
View File
@@ -72,7 +72,7 @@ function dbgClicked(rect) { return Input.mpressed && inRect(Input.mx, Input.my,
// clamp the cursor to the arena so spawned pickups/pedestals never land in walls
function dbgCursor() {
const a = G.arena;
return { x: clamp(Input.mx, a.x0 + 12, a.x1 - 12), y: clamp(Input.my, a.y0 + 12, a.y1 - 12) };
return { x: clamp(mxW(), a.x0 + 12, a.x1 - 12), y: clamp(myW(), a.y0 + 12, a.y1 - 12) };
}
// number row: label + [-] value [+]
@@ -185,7 +185,7 @@ function dbgRenderPlayer(ctx, b) {
{ t: 'KILL PLAYER', fn: () => { p.hp = 0; gameOver(); } },
{ t: '+1 LEVELUP', fn: () => { G.pendingLevelups++; addToast('DEBUG', '+1 pending levelup'); } },
{ t: 'OPEN DRAFT', fn: () => { openPerkDraft(); } },
{ t: 'TP TO CURSOR', fn: () => { p.x = clamp(Input.mx, G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(Input.my, G.arena.y0 + p.r, G.arena.y1 - p.r); } },
{ t: 'TP TO CURSOR', fn: () => { p.x = clamp(mxW(), G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(myW(), G.arena.y0 + p.r, G.arena.y1 - p.r); } },
{ t: '+500 SCORE', fn: () => addScore(500) },
];
for (let i = 0; i < acts.length; i++) {
+1 -1
View File
@@ -198,7 +198,7 @@ const ACTIVES = {
spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8);
} },
offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) {
explodeAt(Input.mx, Input.my, 95, 25 * p.stats.dmgMul, true);
explodeAt(mxW(), myW(), 95, 25 * p.stats.dmgMul, true);
} },
bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) {
p.xp = Math.floor(p.xp * 0.75);
+6 -2
View File
@@ -298,8 +298,11 @@ function draw() {
if (G.mode === 'menu') { drawMenu(ctx); return; }
// world (with screen shake)
updateCamera();
// world (with camera + screen shake)
ctx.save();
ctx.translate(-G.cam.x, -G.cam.y);
if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5);
drawRoom(ctx);
drawCorpses(ctx);
@@ -309,6 +312,7 @@ function draw() {
drawBullets(ctx);
drawParticles(ctx);
drawTelegraphs(ctx);
if (G.devMode) drawDebugOverlays(ctx);
ctx.restore();
if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0);
@@ -340,7 +344,7 @@ function draw() {
else if (G.mode === 'pause') { drawPause(ctx); if (G.pauseHelp) drawPauseHelp(ctx); }
else if (G.mode === 'gameover') drawGameOver(ctx);
else if (G.mode === 'debug') drawDebug(ctx);
if (G.devMode) { drawDebugOverlays(ctx); if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
}
function drawMenu(ctx) {
+1 -1
View File
@@ -95,7 +95,7 @@ function updatePlayer(dt) {
p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r);
// aim
p.aim = angleTo(p.x, p.y, Input.mx, Input.my);
p.aim = angleTo(p.x, p.y, mxW(), myW());
// The waist and legs turn smoothly with travel while the weapon torso aims freely.
// Once planted, the lower body turns back under the independently aimed torso.
if (mx || my) {
+31 -11
View File
@@ -3,11 +3,15 @@
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 ROOM_SHAPES = {
hall: { x0: 48, y0: 48, x1: 912, y1: 592 },
wide_hall: { x0: 48, y0: 208, x1: 912, y1: 432 },
tall_hall: { x0: 368, y0: 48, x1: 592, y1: 592 },
chamber: { x0: 208, y0: 128, x1: 752, y1: 512 },
pit: { x0: 128, y0: 88, x1: 832, y1: 552 },
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)
tall_hall: { x0: 336, y0: 48, x1: 624, y1: 592 }, // w 224 -> 288 (cx stays 480)
chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, // unchanged — item rooms
pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, // unchanged
grand_hall:{ x0: 48, y0: 48, x1: 1680, y1: 592 }, // new, ~1.7× wide — camera scrolls horizontally (w 1632 > 960)
deep_hall: { x0: 480, y0: 48, x1: 912, y1: 1136 }, // new, ~1.85× tall — camera scrolls vertically (h 1088 > 640)
meat_hall: { x0: 48, y0: 48, x1: 1680, y1: 1136 }, // new, 1.7× × 1.85× — camera scrolls both axes
odd_hall: { x0: 160, y0: 128, x1: 800, y1: 512 }, // new, fits screen but off-center — visually "odd" with zero camera
};
const ROOM_THEMES = {
abattoir: { floor: ['tile_floor1', 'tile_floor1', 'tile_floor4', 'tile_floor6'], wall: ['tile_wall', 'tile_wall2'] },
@@ -49,13 +53,14 @@ function genFloor(floor) {
const d = Math.abs(r.gx) + Math.abs(r.gy);
if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; }
}
const combatShapes = ['hall', 'hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall'];
const smallShapes = ['hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall', 'odd_hall'];
const bigShapes = ['grand_hall', 'deep_hall', 'meat_hall'];
const themes = Object.keys(ROOM_THEMES);
for (const r of Object.values(G.rooms)) {
r.theme = choice(themes);
if (r.type === 'start' || r.type === 'boss') r.shape = 'hall';
else if (r.type === 'item') r.shape = 'chamber';
else r.shape = choice(combatShapes);
else r.shape = (G.floor >= 2 && chance(0.30)) ? choice(bigShapes) : choice(smallShapes);
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
}
@@ -143,6 +148,20 @@ function enterRoom(gx, gy) {
}
}
// Isaac-style: the view centers on the player and is clamped so it never leaves
// the room. Rooms that fit the screen keep cam exactly at the room center —
// which is (0,0) for every current shape, so existing rendering is byte-identical.
function updateCamera() {
const a = G.arena, p = G.player;
if (!a || !p) return;
G.cam = G.cam || { x: 0, y: 0 };
G.cam.x = a.w <= W ? a.cx - W / 2 : clamp(p.x - W / 2, a.x0, a.x1 - W);
G.cam.y = a.h <= H ? a.cy - H / 2 : clamp(p.y - H / 2, a.y0, a.y1 - H);
}
// screen-space mouse -> world coords (safe pre-player: cam exists from state.js)
function mxW() { return Input.mx + G.cam.x; }
function myW() { return Input.my + G.cam.y; }
function separateEntryWave() {
const p = G.player;
const a = G.arena;
@@ -338,11 +357,11 @@ const FLOOR_TILE_POOL = [
];
const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4'];
function drawFloorTiles(target, r) {
function drawFloorTiles(target, r, extW, extH) {
const a = roomBounds(r);
const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir;
target.fillStyle = '#050204';
target.fillRect(0, 0, W, H);
target.fillRect(0, 0, extW === undefined ? W : extW, extH === undefined ? H : extH);
target.save();
target.beginPath(); target.rect(a.x0, a.y0, a.w, a.h); target.clip();
const tx0 = Math.floor(a.x0 / TILE), tx1 = Math.ceil(a.x1 / TILE);
@@ -364,11 +383,12 @@ function drawFloor(ctx, r) {
if (!canCache) { drawFloorTiles(ctx, r); return; }
const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme;
if (!G.roomLayer || G.roomLayerKey !== key) {
const a = roomBounds(r);
const layer = document.createElement('canvas');
layer.width = W; layer.height = H;
layer.width = Math.max(W, a.x1); layer.height = Math.max(H, a.y1);
const lctx = layer.getContext('2d');
lctx.imageSmoothingEnabled = false;
drawFloorTiles(lctx, r);
drawFloorTiles(lctx, r, layer.width, layer.height);
G.roomLayer = layer; G.roomLayerKey = key;
}
ctx.drawImage(G.roomLayer, 0, 0);
+1 -1
View File
@@ -111,7 +111,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=51')
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=52')
.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)))
+1 -1
View File
@@ -86,7 +86,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=51';
img.src = 'assets/' + name + '.png?v=52';
}
},
+1
View File
@@ -53,6 +53,7 @@ const G = {
roomLayerKey: '',
atmosphereLayer: null,
arena: { x0: WALL, y0: WALL, x1: W - WALL, y1: H - WALL, cx: W / 2, cy: H / 2, w: W - WALL * 2, h: H - WALL * 2 },
cam: { x: 0, y: 0 }, // world coords of the viewport's top-left; 0,0 for every fit-to-screen room
};
const PERK_POWER_WEIGHT = 0.5;
+3 -3
View File
@@ -100,11 +100,11 @@ function fireWeapon(p, w) {
break;
}
case 'lob': case 'lob_trap': case 'lob_swarm': {
const primaryD = Math.max(dist(bx, by, Input.mx, Input.my), 1);
const primaryD = Math.max(dist(bx, by, mxW(), myW()), 1);
for (let i = 0; i < volley.length; i++) {
const a = volley[i];
const tx = i === 0 ? Input.mx : p.x + Math.cos(a) * primaryD;
const ty = i === 0 ? Input.my : p.y + Math.sin(a) * primaryD;
const tx = i === 0 ? mxW() : p.x + Math.cos(a) * primaryD;
const ty = i === 0 ? myW() : p.y + Math.sin(a) * primaryD;
const d = Math.max(dist(bx, by, tx, ty), 1);
const flight = clamp(d / (520 * spdMul), 0.12, 0.8);
mk(a, {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meatslicer",
"productName": "MeatSlicer",
"version": "0.5.2",
"version": "0.6.0",
"private": true,
"description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js",
+59 -1
View File
@@ -61,7 +61,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, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', 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, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds })', sandbox);
let simTime = 0;
const ctx = sandbox;
@@ -279,6 +279,64 @@ console.log('== variable room shapes and themes ==');
ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py;
}
console.log('== big rooms + camera ==');
{
// fit rooms keep camera pinned at (0,0) — current look is unchanged
const fit = ctx.makeRoom(0, 0, 'combat'); fit.shape = 'hall';
ctx.setArenaForRoom(fit);
ctx.G.player.x = 100; ctx.G.player.y = 320;
ctx.updateCamera();
check('fit rooms keep camera at origin', ctx.G.cam.x === 0 && ctx.G.cam.y === 0);
// wide room: camera follows the player, clamped to the room
const wide = ctx.makeRoom(0, 0, 'combat'); wide.shape = 'grand_hall';
ctx.setArenaForRoom(wide);
const wa = ctx.G.arena;
ctx.G.player.x = 100; ctx.G.player.y = 320; ctx.updateCamera();
check('camera clamps at left arena edge', ctx.G.cam.x === wa.x0);
ctx.G.player.x = wa.x1 - 200; ctx.G.player.y = 320; ctx.updateCamera();
check('camera clamps at right arena edge', ctx.G.cam.x === wa.x1 - ctx.W);
ctx.G.player.x = wa.cx; ctx.G.player.y = 320; ctx.updateCamera();
check('camera centers on the player mid-room', ctx.G.cam.x === wa.cx - ctx.W / 2 && ctx.G.cam.y === 0);
// world-mouse conversion
ctx.G.cam.x = wa.cx - ctx.W / 2; ctx.Input.mx = 120; ctx.Input.my = 240;
check('screen mouse maps to world coords', ctx.mxW() === 120 + ctx.G.cam.x && ctx.myW() === 240);
// end-to-end: live in a big room — update+draw path, floor direct path, aim
ctx.startRun();
const big = ctx.G.rooms['0,0'];
big.shape = 'meat_hall';
ctx.G.cur = big; ctx.setArenaForRoom(big);
ctx.G.player.x = ctx.G.arena.x0 + 80; ctx.G.player.y = ctx.G.arena.y0 + 80;
ctx.G.enemies.length = 0;
ctx.Input.mx = 300; ctx.Input.my = 200; ctx.Input.mdown = true;
step(3, 16); // runs update() (firing, weapon lob path) + draw() (direct floor, camera)
check('big room runs without error and aims at world mouse',
Math.abs(ctx.G.player.aim - ctx.angleTo(ctx.G.player.x, ctx.G.player.y, ctx.Input.mx + ctx.G.cam.x, ctx.Input.my + ctx.G.cam.y)) < 1e-9);
ctx.Input.mdown = false;
ctx.startRun(); step(3, 16);
}
// floor >= 2 mixes big/odd rooms into combat shapes; doors stay reciprocal
{
ctx.G.floor = 6;
let sawBig = false, reciprocal = true;
for (let trial = 0; trial < 40 && !sawBig; trial++) {
ctx.genFloor(6);
for (const r of Object.values(ctx.G.rooms)) {
if (r.type === 'combat' && ['grand_hall', 'deep_hall', 'meat_hall', 'odd_hall'].includes(r.shape)) sawBig = true;
if (r.doors.n && !ctx.G.rooms[(r.gx) + ',' + (r.gy - 1)]) reciprocal = false;
if (r.doors.s && !ctx.G.rooms[(r.gx) + ',' + (r.gy + 1)]) reciprocal = false;
if (r.doors.e && !ctx.G.rooms[(r.gx + 1) + ',' + (r.gy)]) reciprocal = false;
if (r.doors.w && !ctx.G.rooms[(r.gx - 1) + ',' + (r.gy)]) reciprocal = false;
}
}
check('floor 6 mixes in big and odd rooms', sawBig);
check('big-room maps keep reciprocal doors', reciprocal);
ctx.startRun(); step(3, 16);
}
console.log('== XP / perks ==');
ctx.gainXP(100);
step(5, 16);