diff --git a/lib/render.js b/lib/render.js index 8a4681e..1485bf0 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 @@ -747,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) @@ -763,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/) @@ -780,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) } } } @@ -798,6 +875,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 +887,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..81efbf3 100644 --- a/test/index.js +++ b/test/index.js @@ -19,6 +19,8 @@ const { 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