-
Notifications
You must be signed in to change notification settings - Fork 3
fix: residents step aside instead of trading pixels with the hero (#9) #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/) | ||
|
Comment on lines
+762
to
+776
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Bug: actorW uses raw length, diverging from ascii-normalized paintThe reservation/paint loops normalize actor sprite rows with Compute actorW from the ascii-normalized width, matching the loops that render the sprite.:
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/) | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
| 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') | ||
| }) |
| 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') | ||
| }) |
There was a problem hiding this comment.
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
actorOccupiedis 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 👍 / 👎