Files
symph/render/render.go
T
2026-07-15 01:28:56 -04:00

459 lines
15 KiB
Go

// Package render draws GameState to a terminal cell buffer.
//
// Tile encoding (12x5):
// - border — thin ring, colored by nearest-wall proximity band.
// Solid fill only when a wall occupies the current chord
// - interior r1 — wall timer, fixed position. ▼ = chords until the lane
// shuts, ▲ = chords until it reopens. '+' lower bound,
// '!' trap gap
// - interior r2 — distance row: nearest positive and nearest negative
// occurrence, each as glyph + chord-distance. The tile(s) holding the
// grid-wide nearest occurrence of a polarity blink that group
// - interior r3 — lookahead strip: one cell per chord, leftmost = now
// - background — player position
//
// The header row carries LEVEL, the active-effect segment (shield charge, boost countdown), and ENERGY.
//
// The inter-tile gap row of the player lane hosts the row-reset funnel.
package render
import (
"fmt"
"math"
"time"
"unicode/utf8"
"symph/game"
"symph/parameter"
"symph/types"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
type Renderer struct {
term terminal.Terminal
state *game.GameState
cells []terminal.Cell
height, width int
Muted bool
// grid-scope scan and burn-out projection are shared helpers
grid GridScan
fade FadeGrid
}
func NewRenderer(t terminal.Terminal, w, h int, s *game.GameState) *Renderer {
r := &Renderer{term: t, state: s, height: h, width: w, cells: make([]terminal.Cell, w*h)}
r.clearFrame()
return r
}
func (r *Renderer) Resize(w, h int) {
r.height, r.width = h, w
r.cells = make([]terminal.Cell, w*h)
r.clearFrame()
}
// clearFrame blanks the logical buffer. Phase overlays paint outside tile
// rectangles, so per-frame clearing prevents stale cells across phase
// switches; the terminal Flush diffs, keeping the cost in-process
func (r *Renderer) clearFrame() {
for i := range r.cells {
r.cells[i] = terminal.Cell{Rune: ' ', Bg: color.Black}
}
}
// MinSize is the frame size required to draw the full field. Below it
// drawChar clips silently, so the frontend pauses and shows the notice
func MinSize() (w, h int) {
gh, gw := gridSize()
return gw + 2*parameter.RenderMarginX,
gh + parameter.RenderHeaderRows + 2*parameter.RenderMarginY
}
func (r *Renderer) TooSmall() bool {
mw, mh := MinSize()
return r.width < mw || r.height < mh
}
func (r *Renderer) drawTooSmall() {
mw, mh := MinSize()
lines := [...]string{
"TERMINAL TOO SMALL",
fmt.Sprintf("need %dx%d", mw, mh),
fmt.Sprintf("have %dx%d", r.width, r.height),
"PAUSED - RESIZE TO RESUME",
}
top := max(0, (r.height-len(lines))/2)
for i, s := range lines {
fg := color.Silver
if i == 0 {
fg = color.BrightRed
}
drawText(r.cells, r.height, r.width, top+i,
max(0, (r.width-runeLen(s))/2), s, fg, terminal.AttrBold)
}
}
// Render draws one frame: chrome, then the phase-appropriate field
func (r *Renderer) Render(now time.Time) {
// Ensure no terminal size desync
w, h := r.term.Size()
if w <= 0 || h <= 0 || w != r.width || h != r.height {
return
}
r.clearFrame()
if r.TooSmall() {
r.drawTooSmall()
r.term.Flush(r.cells, w, h)
return
}
topY, topX := r.gridOrigin()
_, gridW := gridSize()
hudL := fmt.Sprintf("LEVEL %d", r.state.Level)
if r.Muted {
hudL += " " + parameter.MutedText
}
hudR := fmt.Sprintf("ENERGY %d", r.state.Energy)
drawText(r.cells, h, w, topY-1, topX, hudL, color.CoolSilver, terminal.AttrBold)
drawText(r.cells, h, w, topY-1, topX+gridW-runeLen(hudR), hudR, color.PaleGold, terminal.AttrBold)
// Active effects, centered between the HUD anchors
if s := StatusText(r.state, now); s != "" {
drawText(r.cells, h, w, topY-1, topX+(gridW-runeLen(s))/2, s,
StatusColor(r.state, now), terminal.AttrBold)
}
switch r.state.Phase {
case game.PhaseLevelClear:
r.drawLevelClear(topY, topX, now)
case game.PhaseGameOver:
r.drawChordGrid(topY, topX, now)
r.drawGameOver(topY)
default:
r.drawChordGrid(topY, topX, now)
r.drawResetFunnel(topY, topX, now)
}
r.term.Flush(r.cells, w, h)
}
// --- Geometry ---
// gridSize returns the grid extent including inter-tile gaps
func gridSize() (h, w int) {
h = parameter.GamePlayIndexYMax*parameter.RenderNoteHeight +
(parameter.GamePlayIndexYMax-1)*parameter.RenderGapY
w = parameter.GamePlayIndexXMax*parameter.RenderNoteWidth +
(parameter.GamePlayIndexXMax-1)*parameter.RenderGapX
return h, w
}
// gridOrigin computes the centered top-left cell for the chord grid, clamped below the header row
func (r *Renderer) gridOrigin() (topY, topX int) {
gridH, gridW := gridSize()
topX = max(parameter.RenderMarginX, (r.width-gridW)/2)
topY = (r.height - gridH) / 2
topY = max(topY, parameter.RenderMarginY+parameter.RenderHeaderRows)
topY = min(topY, r.height-parameter.RenderMarginY-gridH)
return topY, topX
}
// tileOrigin resolves the top-left cell of grid tile (y,x)
func tileOrigin(topY, topX, y, x int) (int, int) {
return topY + y*(parameter.RenderNoteHeight+parameter.RenderGapY),
topX + x*(parameter.RenderNoteWidth+parameter.RenderGapX)
}
// --- Playfield ---
func (r *Renderer) drawChordGrid(topY, topX int, now time.Time) {
r.grid.Refresh(r.state)
r.fade.Refresh(r.state, now)
for iy := range parameter.GamePlayIndexYMax {
for ix := range parameter.GamePlayIndexXMax {
ty, tx := tileOrigin(topY, topX, iy, ix)
r.drawTile(ty, tx, iy, ix, now)
}
}
}
// drawTile renders one grid position: wall-encoded border, distance row, wall
// timer, lookahead strip, and the player background fill. Background fill runs
// last so it recolors without clobbering foreground glyphs
func (r *Renderer) drawTile(topY, topX, y, x int, now time.Time) {
tl := r.grid.Tiles[y][x]
seg := tl.NearestWall()
switch {
case seg.Distance == 0:
r.drawWallRing(topY, topX, WallSolid, WallBandColor(0))
case seg.Distance > 0:
r.drawBoxRing(topY, topX, WallBandColor(seg.Distance))
default:
r.drawBoxRing(topY, topX, RingIdle)
}
// Timers and the blink are meaningless outside a running PhasePlaying:
// LastPlayedTime is frozen and the field does not advance
live := r.state.Phase == game.PhasePlaying && !r.state.Paused
r.drawWallTimer(topY+parameter.RenderRowTimer, topX, seg, live, now)
r.drawDistRow(topY+parameter.RenderRowDist, topX, tl, live, now)
r.drawStrip(topY+parameter.RenderRowStrip, topX, tl, y, x) // Fade lookup needs (y,x)
if y == r.state.PlayerState.PlayIndexY && x == r.state.PlayerState.PlayIndexX {
r.fillTileBg(topY, topX, PlayerBgFor(r.state.Status, now))
}
}
// drawWallTimer renders the fixed-position wall countdown.
func (r *Renderer) drawWallTimer(row, topX int, seg game.WallSegment, live bool, now time.Time) {
if !live {
return
}
s, fg := WallTimerText(TimerUnicode, r.state, seg, now)
if s == "" {
return
}
r.drawTileText(row, topX, s, fg, terminal.AttrBold)
}
// drawStrip renders the lane timeline — one cell per chord distance, leftmost
// = the current chord. Distance is carried positionally, so items render at
// their arrived color and stay legible at range; walls keep the band gradient,
// matching the ring. A cell emptied by consumption burns out in place
func (r *Renderer) drawStrip(row, topX int, tl game.TileLookahead, y, x int) {
col := topX + 1 // inner == PositionLookaheadWindow
for d := range parameter.PositionLookaheadWindow {
ch, fg := parameter.StripEmptyChar, color.DimGray
if d < tl.Window {
switch v := tl.Cells[d]; {
case v == types.ValueWall:
ch, fg = TileGlyph(v, d)
case v != types.ValueNone:
ch, fg = TileGlyph(v, 0)
default:
// burn-out of a note the engine just consumed
if fv, t, ok := r.fade.At(y, x, d); ok {
ch, fg = FadeVisual(fv, t)
}
}
}
attr := terminal.AttrNone
if d == 0 {
attr = terminal.AttrBold // the chord being played
}
drawChar(r.cells, r.height, r.width, row, col+d, ch, fg, attr)
}
}
// drawWallRing fills the tile border with the wall proximity glyph. Adjacent
// walled tiles merge into a contiguous mass
func (r *Renderer) drawWallRing(topY, topX int, glyph rune, fg color.RGB) {
for ty := range parameter.RenderNoteHeight {
for tx := range parameter.RenderNoteWidth {
if ty > 0 && ty < parameter.RenderNoteHeight-1 &&
tx > 0 && tx < parameter.RenderNoteWidth-1 {
continue
}
drawChar(r.cells, r.height, r.width, topY+ty, topX+tx, glyph, fg, terminal.AttrNone)
}
}
}
// drawBoxRing draws the wall-free border from the tile template
// ring color is a parameter — carries wall proximity when the wall
// has not arrived
func (r *Renderer) drawBoxRing(topY, topX int, fg color.RGB) {
for ty := range parameter.RenderNoteHeight {
for tx := range parameter.RenderNoteWidth {
if ch := noteChar[ty][tx]; ch != 0 {
drawChar(r.cells, r.height, r.width, topY+ty, topX+tx, ch, fg, terminal.AttrNone)
}
}
}
}
// drawDistRow draws two fixed polarity groups: nearest help, nearest threat,
// each as glyph + chord-distance. 0 = at the current chord; an absent kind
// draws nothing. A group whose distance equals the grid-wide nearest for its
// polarity blinks — several tiles can tie and all of them blink
func (r *Renderer) drawDistRow(row, topX int, tl game.TileLookahead, live bool, now time.Time) {
if tl.Positive.Distance < 0 && tl.Negative.Distance < 0 {
return
}
inner := parameter.RenderNoteWidth - 2
col := topX + 1 + (inner-parameter.RenderDistCols)/2
// grid-wide minima read from GridScan
r.drawDistGroup(row, col, tl.Positive,
live && tl.Positive.Distance == r.grid.NearPos, BlinkPositive, now)
r.drawDistGroup(row, col+parameter.RenderDistDigits+2, tl.Negative,
live && tl.Negative.Distance == r.grid.NearNeg, BlinkNegative, now)
}
func (r *Renderer) drawDistGroup(row, col int, it game.ItemStat, blink bool, pair [2]color.RGB, now time.Time) {
if it.Distance < 0 {
return
}
ch, fg := TileGlyph(it.Value, it.Distance)
if ch == 0 {
return
}
if blink {
fg = BlinkColor(pair, now)
}
drawChar(r.cells, r.height, r.width, row, col, ch, fg, terminal.AttrBold)
drawText(r.cells, r.height, r.width, row, col+1, DistText(it.Distance), fg, terminal.AttrBold)
}
// drawResetFunnel renders the pending row-reset countdown in the gap row
// adjacent to the base-row tile of the player lane. Funnel orientation follows
// the return direction (fall after a jump, rise after a slide)
func (r *Renderer) drawResetFunnel(topY, topX int, now time.Time) {
p := r.state.PlayerState
if p.ResetTime.IsZero() || p.PlayIndexY == p.BaseIndexY {
return
}
baseY, laneX := tileOrigin(topY, topX, p.BaseIndexY, p.PlayIndexX)
format, row := parameter.DropFunnelFormat, baseY-parameter.RenderGapY
if p.PlayIndexY > p.BaseIndexY {
format, row = parameter.RiseFunnelFormat, baseY+parameter.RenderNoteHeight
}
s := fmt.Sprintf(format, TimerText(p.ResetTime.Sub(now)))
drawText(r.cells, r.height, r.width, row,
laneX+(parameter.RenderNoteWidth-len(s))/2, s, color.White, terminal.AttrBold)
}
// --- Phase overlays ---
// transitionPalette cycles the level-clear wave hues
var transitionPalette = [...]color.RGB{
color.Vermilion, color.TigerOrange, color.Gold,
color.BrightGreen, color.BrightCyan, color.Cornflower, color.HotMagenta,
}
// drawLevelClear paints a radial color wave expanding from the grid center
// with the clear banner on top — retro inter-level interlude
func (r *Renderer) drawLevelClear(topY, topX int, now time.Time) {
gridH, gridW := gridSize()
const padY, padX = 1, 3
cy := float64(topY) + float64(gridH-1)/2
cx := float64(topX) + float64(gridW-1)/2
el := now.Sub(r.state.PhaseStart).Seconds()
n := float64(len(transitionPalette))
for y := topY - padY; y < topY+gridH+padY; y++ {
for x := topX - padX; x < topX+gridW+padX; x++ {
dy := (float64(y) - cy) * 2.0 // terminal cell aspect ~1:2
dx := float64(x) - cx
ph := math.Hypot(dx, dy)*0.35 - el*8
pos := math.Mod(math.Mod(ph, n)+n, n) // positive wrap into palette cycle
i := int(pos)
frac := pos - float64(i)
col := transitionPalette[i].Lerp(transitionPalette[(i+1)%len(transitionPalette)], frac)
glyph := parameter.Density256Chars[int(frac*float64(len(parameter.Density256Chars)))]
drawChar(r.cells, r.height, r.width, y, x, glyph, col, terminal.AttrNone)
}
}
msg := fmt.Sprintf(" LEVEL %d CLEAR ", r.state.Level)
sub := fmt.Sprintf(" ENERGY %d ", r.state.Energy)
midY := topY + gridH/2
fg := color.White
if int(el*4)%2 == 0 {
fg = color.PaleLemon // blink
}
drawText(r.cells, r.height, r.width, midY-1, max(0, (r.width-len(msg))/2), msg, fg, terminal.AttrBold)
drawText(r.cells, r.height, r.width, midY+1, max(0, (r.width-len(sub))/2), sub, color.Silver, terminal.AttrBold)
}
// drawGameOver overlays the death banner on the frozen grid (fatal wall stays
// visible)
func (r *Renderer) drawGameOver(topY int) {
gridH, _ := gridSize()
midY := topY + gridH/2
msg := " G A M E O V E R "
sub := fmt.Sprintf(" ENERGY %d - LEVEL %d ", r.state.Energy, r.state.Level)
hint := " ENTER TO RESTART "
drawText(r.cells, r.height, r.width, midY-1, max(0, (r.width-len(msg))/2), msg, color.BrightRed, terminal.AttrBold)
drawText(r.cells, r.height, r.width, midY, max(0, (r.width-len(sub))/2), sub, color.Silver, terminal.AttrNone)
drawText(r.cells, r.height, r.width, midY+1, max(0, (r.width-len(hint))/2), hint, color.DimSilver, terminal.AttrNone)
}
// --- Cell primitives ---
func runeLen(s string) int { return utf8.RuneCountInString(s) }
// drawTileText centers s across the tile interior columns. Width is
// rune-indexed; byte length misplaces the multi-byte timer affixes
func (r *Renderer) drawTileText(row, topX int, s string, fg color.RGB, attr terminal.Attr) {
inner := parameter.RenderNoteWidth - 2
n := runeLen(s)
if n > inner {
return
}
drawText(r.cells, r.height, r.width, row, topX+1+(inner-n)/2, s, fg, attr)
}
// fillTileBg recolors the tile rectangle background in place. Runs after all
// tile glyphs are written, so foreground content is preserved
func (r *Renderer) fillTileBg(topY, topX int, bg color.RGB) {
for ty := range parameter.RenderNoteHeight {
y := topY + ty
if y < 0 || y >= r.height {
continue
}
for tx := range parameter.RenderNoteWidth {
x := topX + tx
if x < 0 || x >= r.width {
continue
}
r.cells[y*r.width+x].Bg = bg
}
}
}
// drawChar writes a single cell, bounds-checked
func drawChar(cells []terminal.Cell, h, w, y, x int, char rune, fg color.RGB, attr terminal.Attr) {
if y < 0 || y >= h || x < 0 || x >= w {
return
}
cells[y*w+x] = terminal.Cell{Rune: char, Fg: fg, Bg: color.Black, Attrs: attr}
}
// drawText writes a horizontal string, rune-indexed (byte indexing misplaces
// columns for non-ASCII text). Out-of-bounds columns are skipped
func drawText(cells []terminal.Cell, h, w, y, x int, text string, fg color.RGB, attr terminal.Attr) {
if y < 0 || y >= h {
return
}
sx := x
for _, r := range text {
if sx >= 0 && sx < w {
cells[y*w+sx] = terminal.Cell{Rune: r, Fg: fg, Bg: color.Black, Attrs: attr}
}
sx++
}
}