From 2297d19c4e0ae6e4bd6d446ec9abf82feb893a83 Mon Sep 17 00:00:00 2001 From: byblik Date: Tue, 25 Aug 2026 04:26:46 +0300 Subject: [PATCH] fix: the hero sprite no longer paints through walls (#16) The overlay loop painted every hero cell unconditionally, so the rows of the body that hang above the feet landed on solid tiles and off-map cells and read as holes in the wall, most visibly on every dungeon arrival. Hero cells are now skipped when the tile beneath them is solid, checked against the canonical isSolid table instead of render-local guesses. --- lib/render.js | 14 +++++++++++++- test/hero-clip.test.js | 44 ++++++++++++++++++++++++++++++++++++++++++ test/index.js | 1 + 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/hero-clip.test.js diff --git a/lib/render.js b/lib/render.js index 8a4681e..416ce3c 100755 --- a/lib/render.js +++ b/lib/render.js @@ -33,6 +33,7 @@ const { style } = require('bare-tui') const { makeMovingHeroSprite } = require('./sprites.js') const { WORLD_BOSS } = require('./world-boss.js') const { bossCamera } = require('./world-boss-event.js') +const { isSolid } = require('./map.js') /** Smallest terminal the split layout is designed for. */ const MIN_WIDTH = 64 @@ -798,6 +799,10 @@ function mapPane(map, w, h, opts = {}) { : sprite.length - 1 const top = hy - clamp(heroAnchorY, 0, sprite.length - 1) + // isSolid reads the model shape ({ rows, width, height }); this pane gets + // pre-render data ({ tiles }), so adapt the view once per frame (#16). + const solidView = { rows: tiles, width: mapW, height: mapH } + for (let sy = 0; sy < sprite.length; sy++) { const line = ascii(sprite[sy]).padEnd(spriteW) const first = line.search(/\S/) @@ -806,7 +811,14 @@ function mapPane(map, w, h, opts = {}) { while (last > first && line[last] === ' ') last-- for (let sx = first; sx <= last; sx++) { if (line[sx] === ' ') continue - const cell = top + sy + ',' + (left + sx) + const wx = left + sx + const wy = top + sy + // The body hangs above the feet and can reach over a wall or past the + // map edge. A hero glyph on a solid tile reads as a hole in that wall, + // so those cells stay untouched (#16). With no tiles at all there is + // nothing to clip against and the fallback keeps rendering whole. + if (tiles.length > 0 && isSolid(solidView, wx, wy)) continue + const cell = wy + ',' + wx over.set(cell, line[sx]) heroCells.add(cell) } diff --git a/test/hero-clip.test.js b/test/hero-clip.test.js new file mode 100644 index 0000000..c179ed8 --- /dev/null +++ b/test/hero-clip.test.js @@ -0,0 +1,44 @@ +const { test } = require('brittle') +const { style } = require('bare-tui') +const render = require('../lib/render.js') + +test('the hero never paints through a wall (#16)', (t) => { + // Dungeon-style arrival: the three-row hero stands with its feet two rows + // below a full wall, like arriving at (3,2) in the issue. The head row of + // the sprite lands on the wall and must be clipped away. + const tiles = ['######', '#....#', '#....#', '######'] + const pane = style.stripAnsi( + render.mapPane({ tiles, actors: [], hero: { x: 3, y: 2, sprite: [' O ', '/|\\', '/ \\'] } }, 24, 12, { + cellW: 1 + }) + ) + + t.ok(pane.includes('######'), 'the top wall stays a continuous wall') + t.ok(!pane.includes('O'), 'the head is clipped instead of punching a hole in the wall') + t.ok(pane.includes('|'), 'the torso still shows on the free row below') +}) + +test('a hero arriving at the top edge loses only the off-map rows (#16)', (t) => { + // Field arrival at (40,1): the upper body reaches past row 0. Off-map cells + // read as solid, so they get skipped, while the feet stay put. + const tiles = ['........', '........', '########'] + const pane = style.stripAnsi( + render.mapPane({ tiles, actors: [], hero: { x: 4, y: 0, sprite: ['O', '|', '^'] } }, 20, 8, { + cellW: 1 + }) + ) + + t.ok(!pane.includes('O'), 'nothing is painted above the map') + t.ok(pane.includes('^'), 'the feet remain exactly where the walker stands') + t.ok(style.stripAnsi(render.mapPane({ tiles, actors: [], hero: { x: 4, y: 0 } }, 20, 8)).includes('@'), 'glyph heroes keep rendering') +}) + +test('a hero without map data keeps rendering (fallback path unchanged)', (t) => { + const pane = style.stripAnsi( + render.mapPane({ tiles: [], actors: [], hero: { x: 5, y: 5, sprite: ['O', '|', '^'] } }, 20, 8, { + cellW: 1 + }) + ) + + t.ok(pane.includes('O'), 'with no tiles there is nothing solid to clip against') +}) \ No newline at end of file diff --git a/test/index.js b/test/index.js index 7be3ead..c7ec8c9 100644 --- a/test/index.js +++ b/test/index.js @@ -19,6 +19,7 @@ const { const render = require('../lib/render.js') require('./sage.test.js') +require('./hero-clip.test.js') function press(game, name) { return game.onKey({ type: 'key', is: (...keys) => keys.includes(name) })