From f6f3f6515d444704773e79a23d435170e2f61cd4 Mon Sep 17 00:00:00 2001 From: byblik Date: Tue, 25 Aug 2026 04:26:46 +0300 Subject: [PATCH 1/2] 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) }) From 98f69ee9cea1808591bd9a8c5dc6787829ffef25 Mon Sep 17 00:00:00 2001 From: byblik Date: Tue, 25 Aug 2026 10:21:09 +0300 Subject: [PATCH 2/2] fix: residents step aside instead of trading pixels with the hero (#9) A resident sprite is seven columns wide but blocks one logical cell, and talk distance reaches one or two tiles, so the hero body and the resident body shared screen cells and overwrote each other exactly when e hablar mattered. mapPane now reserves the cells the hero is about to paint and slides a colliding resident drawing sideways - the same presentation-only separation fieldPane applies to an active foe - clamped to the camera, falling back to skipping just the contested cells. Logical positions, collision and interaction range stay untouched. Stacked on the #16 clip, whose solid-cell reservation it reuses. --- lib/render.js | 82 ++++++++++++++++++++++++++++++-- test/index.js | 1 + test/npc-hero-separation.test.js | 40 ++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 test/npc-hero-separation.test.js diff --git a/lib/render.js b/lib/render.js index 416ce3c..1485bf0 100755 --- a/lib/render.js +++ b/lib/render.js @@ -748,9 +748,48 @@ function mapPane(map, w, h, opts = {}) { mapW <= cols ? -Math.floor((cols - mapW) / 2) : clamp(hx - Math.floor(cols / 2), 0, mapW - cols) const camY = mapH <= h ? -Math.floor((h - mapH) / 2) : clamp(hy - Math.floor(h / 2), 0, mapH - h) - // Overlay everything that moves, hero last so nothing can hide the player. + // Overlay everything that moves. Resident bodies are painted first and step + // aside when their drawn footprint would cover the player (#9), mirroring + // what fieldPane does for an active foe: presentation separates, logical + // positions and talk distance stay untouched. const over = new Map() const actorColors = new Map() + + // Reservation pass: the exact cells the hero is about to paint, computed + // with the same maths as the hero pass below (including the #16 solid clip). + const heroReserved = new Set() + const actorOccupied = new Set() + let heroGeom = null + { + const lines_ = Array.isArray(hero.sprite) && hero.sprite.length ? hero.sprite : null + if (lines_) { + const hw = lines_.reduce((widest, line) => Math.max(widest, ascii(line).length), 1) + const hLeft = hx - clamp(HERO_ANCHOR_X, 0, hw - 1) + const heroAnchorY = Number.isFinite(Number(hero.anchorY)) + ? Math.round(Number(hero.anchorY)) + : lines_.length - 1 + const hTop = hy - clamp(heroAnchorY, 0, lines_.length - 1) + heroGeom = { left: hLeft, width: hw } + const solidView = { rows: tiles, width: mapW, height: mapH } + for (let sy = 0; sy < lines_.length; sy++) { + const line = ascii(lines_[sy]).padEnd(hw) + const first = line.search(/\S/) + if (first === -1) continue + let last = line.length - 1 + while (last > first && line[last] === ' ') last-- + for (let sx = first; sx <= last; sx++) { + if (line[sx] === ' ') continue + const wx = hLeft + sx + const wy = hTop + sy + if (tiles.length > 0 && isSolid(solidView, wx, wy)) continue + heroReserved.add(wy + ',' + wx) + } + } + } else { + heroReserved.add(hy + ',' + hx) + } + } + for (const a of (map && map.actors) || []) { if (!a) continue const ax = Math.round(Number(a.x) || 0) @@ -764,12 +803,47 @@ function mapPane(map, w, h, opts = {}) { continue } - const actorW = actorSprite.reduce((widest, line) => Math.max(widest, ascii(line).length), 1) - const left = ax - Math.floor(actorW / 2) + const actorW = actorSprite.reduce((widest, line) => Math.max(widest, line.length), 1) + const anchorX = Math.floor(actorW / 2) + let left = ax - anchorX const anchorY = Number.isFinite(Number(a.anchorY)) ? Math.round(Number(a.anchorY)) : Math.floor(actorSprite.length / 2) const top = ay - clamp(anchorY, 0, actorSprite.length - 1) + + // Presentation-only separation (#9): while talk distance puts a resident + // one or two tiles from the player, its seven-column body and the four + // column hero would share screen cells and eat each other. Slide the + // drawing sideways until it clears the reserved hero cells - same idea as + // fieldPane's active foe - clamped to the camera, falling back to skipping + // just the contested cells if even that cannot fit. + const collidesHero = (l) => { + for (let sy = 0; sy < actorSprite.length; sy++) { + const line = ascii(actorSprite[sy]).padEnd(actorW) + for (let sx = 0; sx < actorW; sx++) { + if (line[sx] === ' ') continue + if (heroReserved.has(top + sy + ',' + (l + sx))) return true + } + } + return false + } + if (collidesHero(left)) { + const wantedSide = Math.sign(ax - hx) || 1 + // Hero body extents relative to hx, mirroring fieldPane's gap maths. + const heroRightGap = heroGeom.left + heroGeom.width - 1 - hx + const heroLeftGap = hx - heroGeom.left + const rightGap = heroRightGap + anchorX + 1 + const leftGap = heroLeftGap + (actorW - 1 - anchorX) + 1 + const preferredC = wantedSide > 0 ? hx + rightGap : hx - leftGap + const alternateC = wantedSide > 0 ? hx - leftGap : hx + rightGap + const minC = camX + anchorX + const maxC = camX + cols - 1 - (actorW - 1 - anchorX) + const clampC = (c) => clamp(c, Math.min(minC, maxC), Math.max(minC, maxC)) + left = clampC(preferredC) - anchorX + if (collidesHero(left)) { + left = clampC(alternateC) - anchorX + } + } for (let sy = 0; sy < actorSprite.length; sy++) { const line = ascii(actorSprite[sy]).padEnd(actorW) const first = line.search(/\S/) @@ -781,8 +855,10 @@ function mapPane(map, w, h, opts = {}) { // the street beneath an arm or between two legs as a black rectangle. if (line[sx] === ' ') continue const cell = top + sy + ',' + (left + sx) + if (heroReserved.has(cell)) continue over.set(cell, line[sx]) actorColors.set(cell, a.color || COLOR.foe) + actorOccupied.add(cell) } } } diff --git a/test/index.js b/test/index.js index c7ec8c9..81efbf3 100644 --- a/test/index.js +++ b/test/index.js @@ -20,6 +20,7 @@ const render = require('../lib/render.js') require('./sage.test.js') require('./hero-clip.test.js') +require('./npc-hero-separation.test.js') function press(game, name) { return game.onKey({ type: 'key', is: (...keys) => keys.includes(name) }) diff --git a/test/npc-hero-separation.test.js b/test/npc-hero-separation.test.js new file mode 100644 index 0000000..e280901 --- /dev/null +++ b/test/npc-hero-separation.test.js @@ -0,0 +1,40 @@ +const { test } = require('brittle') +const { style } = require('bare-tui') +const render = require('../lib/render.js') + +const NPC = ['(-_-_-)', '[|ooo|]', '(/___\\)'] + +function frame(heroX, heroY, npcX, npcY) { + return style.stripAnsi( + render.mapPane( + { + tiles: Array.from({ length: 8 }, () => '..........'), + actors: [{ x: npcX, y: npcY, sprite: NPC, color: 'green' }], + hero: { x: heroX, y: heroY, sprite: [' O ', '/|\\', '/ \\'] } + }, + 16, + 10, + { cellW: 1 } + ) + ) +} + +const countOf = (text, ch) => text.split(ch).length - 1 + +test('talking distance keeps both bodies whole instead of eating pixels (#9)', (t) => { + const pane = frame(3, 4, 5, 3) + + t.is(countOf(pane, 'O'), 1, 'the hero head survives once') + t.ok(pane.includes('[|ooo|]'), 'the resident torso is never punched through') + t.ok(pane.includes('(-_-_-)'), 'the resident head row stays contiguous') + t.ok(pane.includes('/___\\'), 'the resident feet row stays contiguous') + t.ok(pane.includes('/|\\'), 'the hero torso stays contiguous') +}) + +test('far from the hero a resident renders exactly where it stands (#9)', (t) => { + const pane = frame(0, 7, 5, 3) + const row = pane.split('\n').find((line) => line.includes('[|ooo|]')) + // mapW(10) centers inside cols(16): camX = -3, so world col 2 lands on + // screen col 5. + t.is(row.indexOf('[|ooo|]'), 5, 'no shift is applied without an overlap to solve') +}) \ No newline at end of file