// 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++ } }