Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 92 additions & 4 deletions lib/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: actorOccupied set is populated but never read

actorOccupied is declared at line 759 and written to at line 859 but never read anywhere in the function or elsewhere in the file. It is dead code that adds per-cell overhead each frame. Remove the declaration and the .add() call unless a follow-up consumer is intended.

Was this helpful? React with 👍 / 👎

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/)
Comment on lines +762 to +776

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: heroGeom null deref crashes glyph-hero + overlapping actor

When the hero has no sprite (glyph fallback), the reservation block leaves heroGeom = null but still reserves the hero cell hy,hx. If an actor's drawn footprint covers that cell, collidesHero(left) returns true and the code dereferences heroGeom.left/heroGeom.width (lines 831-832), throwing TypeError: Cannot read properties of null and crashing the render. Set a fallback geometry in the else branch (e.g. heroGeom = { left: hx, width: 1 }) so the separation maths works for glyph heroes too.

Give the glyph-hero fallback a valid geometry so the actor step-aside pass never dereferences null.:

} else {
  heroReserved.add(hy + ',' + hx)
  heroGeom = { left: hx, width: 1 }
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

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)
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: actorW uses raw length, diverging from ascii-normalized paint

The reservation/paint loops normalize actor sprite rows with ascii(...).padEnd(actorW), but actorW is now computed from raw line.length (line 804) after this PR dropped the previous ascii(line).length. For sprites containing astral (surrogate-pair) characters the two widths disagree, so padEnd(actorW) and the collision scan can misalign. NPC art is currently ASCII so impact is latent; restore ascii(line).length to keep width computation consistent with rendering.

Compute actorW from the ascii-normalized width, matching the loops that render the sprite.:

const actorW = actorSprite.reduce((widest, line) => Math.max(widest, ascii(line).length), 1)
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

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/)
Expand All @@ -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)
}
}
}
Expand All @@ -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/)
Expand All @@ -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)
}
Expand Down
44 changes: 44 additions & 0 deletions test/hero-clip.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
2 changes: 2 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down
40 changes: 40 additions & 0 deletions test/npc-hero-separation.test.js
Original file line number Diff line number Diff line change
@@ -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')
})