v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"symph/parameter"
|
||||
)
|
||||
|
||||
type noteTile = [parameter.RenderNoteHeight][parameter.RenderNoteWidth]rune
|
||||
|
||||
var noteChar noteTile
|
||||
|
||||
func init() {
|
||||
funnel := max(
|
||||
len(fmt.Sprintf(parameter.DropFunnelFormat, "0.0")),
|
||||
len(fmt.Sprintf(parameter.RiseFunnelFormat, "0.0")),
|
||||
)
|
||||
inner := parameter.RenderNoteWidth - 2
|
||||
|
||||
// inner == PositionLookaheadWindow by construction; assert the
|
||||
// distance row and funnel still fit
|
||||
if parameter.RenderNoteHeight < 2+parameter.RenderRowStrip ||
|
||||
inner < parameter.RenderDistCols ||
|
||||
parameter.RenderNoteWidth < funnel ||
|
||||
parameter.RenderGapY < 1 {
|
||||
panic("Render tile too small: need height>=5, width-2>=RenderDistCols, width>=funnel, gapY>=1")
|
||||
}
|
||||
|
||||
noteChar[0][0] = parameter.BorderSingleTopLeft
|
||||
noteChar[parameter.RenderNoteHeight-1][0] = parameter.BorderSingleBottomLeft
|
||||
noteChar[0][parameter.RenderNoteWidth-1] = parameter.BorderSingleTopRight
|
||||
noteChar[parameter.RenderNoteHeight-1][parameter.RenderNoteWidth-1] = parameter.BorderSingleBottomRight
|
||||
|
||||
for i := 1; i < parameter.RenderNoteWidth-1; i++ {
|
||||
noteChar[0][i] = parameter.BorderSingleHorizontal
|
||||
noteChar[parameter.RenderNoteHeight-1][i] = parameter.BorderSingleHorizontal
|
||||
}
|
||||
|
||||
for i := 1; i < parameter.RenderNoteHeight-1; i++ {
|
||||
noteChar[i][0] = parameter.BorderSingleVertical
|
||||
noteChar[i][parameter.RenderNoteWidth-1] = parameter.BorderSingleVertical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"symph/game"
|
||||
"symph/parameter"
|
||||
"symph/types"
|
||||
)
|
||||
|
||||
// FadeGrid projects the engine's consumed-note ring onto strip coordinates for
|
||||
// one frame: burn-out progress per (y, x, chord-distance). Entries outside the
|
||||
// window or past ItemFadeDuration are dropped. A consumed cell at distance 0
|
||||
// scrolls off on the next beat; the fade is shorter than the tightest beat, so
|
||||
// it always resolves before the strip shifts
|
||||
type FadeGrid struct {
|
||||
value [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax][game.TileLookaheadMaxWindow]types.Value
|
||||
t [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax][game.TileLookaheadMaxWindow]float64
|
||||
}
|
||||
|
||||
func (f *FadeGrid) Refresh(s *game.GameState, now time.Time) {
|
||||
*f = FadeGrid{}
|
||||
for _, c := range s.Consumed() {
|
||||
if c.Value == types.ValueNone {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(c.At)
|
||||
if age < 0 || age >= parameter.ItemFadeDuration {
|
||||
continue
|
||||
}
|
||||
d := c.Z - s.CurrentPlayIndexZ
|
||||
if d < 0 || d >= game.TileLookaheadMaxWindow {
|
||||
continue
|
||||
}
|
||||
f.value[c.Y][c.X][d] = c.Value
|
||||
f.t[c.Y][c.X][d] = float64(age) / float64(parameter.ItemFadeDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// At reports the item burning out at a strip cell and its progress in [0,1)
|
||||
func (f *FadeGrid) At(y, x, d int) (types.Value, float64, bool) {
|
||||
if d < 0 || d >= game.TileLookaheadMaxWindow {
|
||||
return types.ValueNone, 0, false
|
||||
}
|
||||
v := f.value[y][x][d]
|
||||
return v, f.t[y][x][d], v != types.ValueNone
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//go:build linux
|
||||
|
||||
// Package raylib is the graphical frontend. Presentation parity with the
|
||||
// terminal renderer: wall-band ring, wall timer, distance row with grid-wide
|
||||
// blink, lookahead strip, player fill, reset funnel, consumption burn-out.
|
||||
// Policy is sourced from package render; only the medium differs.
|
||||
//
|
||||
// The raylib default font atlas covers codepoints 32..126
|
||||
// Walls draw as rectangles, only the distance row needs the substitute rune.
|
||||
package raylib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
rl "github.com/gen2brain/raylib-go/raylib"
|
||||
|
||||
"symph/game"
|
||||
"symph/parameter"
|
||||
"symph/render"
|
||||
"symph/types"
|
||||
|
||||
"github.com/lixenwraith/color"
|
||||
)
|
||||
|
||||
const (
|
||||
gridFrac = 0.92 // grid extent as a fraction of the usable area
|
||||
tileAspect = 0.62 // tile height / width; mirrors the 12x5 terminal tile
|
||||
gapFrac = 0.03
|
||||
|
||||
// Independent gap fractions. The row gap hosts the reset funnel,
|
||||
// so it scales with tile height, not with usable width
|
||||
gapFracX = 0.04 // column gap / usable width
|
||||
gapFracY = 0.22 // row gap / tile height
|
||||
|
||||
// Funnel countdown geometry
|
||||
funnelFrac = 0.85 // font size / row gap
|
||||
funnelFontMin int32 = 22
|
||||
|
||||
ringPx = 2.0
|
||||
ringWallPx = 4.0
|
||||
wallBodyA = 70 // alpha of the arrived-wall body fill
|
||||
|
||||
rowTimerY = 0.16 // tile-relative content rows
|
||||
rowDistY = 0.42
|
||||
rowStripY = 0.74
|
||||
|
||||
fontHUD int32 = 20
|
||||
fontBanner int32 = 40
|
||||
)
|
||||
|
||||
// Renderer draws GameState with raylib primitives
|
||||
type Renderer struct {
|
||||
state *game.GameState
|
||||
grid render.GridScan
|
||||
fade render.FadeGrid
|
||||
|
||||
Muted bool
|
||||
}
|
||||
|
||||
func NewRenderer(s *game.GameState) *Renderer {
|
||||
return &Renderer{state: s}
|
||||
}
|
||||
|
||||
func toRl(c color.RGB) rl.Color { return rl.NewColor(c.R, c.G, c.B, 255) }
|
||||
|
||||
// Draw renders one frame. Call between rl.BeginDrawing()/EndDrawing()
|
||||
func (r *Renderer) Draw(screenW, screenH int32, now time.Time) {
|
||||
rl.ClearBackground(rl.Black)
|
||||
|
||||
hud := float32(screenH) * 0.07
|
||||
lvl := fmt.Sprintf("LEVEL %d", r.state.Level)
|
||||
if r.Muted {
|
||||
lvl += " " + parameter.MutedText
|
||||
}
|
||||
rl.DrawText(lvl, 12, 10, fontHUD, toRl(color.CoolSilver))
|
||||
en := fmt.Sprintf("ENERGY %d", r.state.Energy)
|
||||
rl.DrawText(en, screenW-rl.MeasureText(en, fontHUD)-12, 10, fontHUD, toRl(color.PaleGold))
|
||||
|
||||
if s := render.StatusText(r.state, now); s != "" {
|
||||
drawCentered(s, float32(screenW)/2, 10, fontHUD, toRl(render.StatusColor(r.state, now)))
|
||||
}
|
||||
|
||||
switch r.state.Phase {
|
||||
case game.PhaseLevelClear:
|
||||
r.drawLevelClear(screenW, screenH, now)
|
||||
case game.PhaseGameOver:
|
||||
r.drawField(screenW, screenH, hud, now)
|
||||
r.drawGameOver(screenW, screenH)
|
||||
default:
|
||||
r.drawField(screenW, screenH, hud, now)
|
||||
}
|
||||
}
|
||||
|
||||
// tileGeom is the resolved per-frame grid layout
|
||||
type tileGeom struct{ ox, oy, tw, th, gx, gy float32 }
|
||||
|
||||
func layout(w, h int32, hud float32) tileGeom {
|
||||
nx := float32(parameter.GamePlayIndexXMax)
|
||||
ny := float32(parameter.GamePlayIndexYMax)
|
||||
|
||||
availW := float32(w) * gridFrac
|
||||
availH := (float32(h) - hud) * gridFrac
|
||||
|
||||
g := tileGeom{}
|
||||
g.gx = availW * gapFracX
|
||||
|
||||
g.tw = (availW - (nx-1)*g.gx) / nx
|
||||
g.th = g.tw * tileAspect
|
||||
g.gy = g.th * gapFracY // Row gap derives from tile height
|
||||
|
||||
// Height-bound screens: refit from the vertical budget.
|
||||
// gy is proportional to th, so solve for th directly
|
||||
if ny*g.th+(ny-1)*g.gy > availH {
|
||||
g.th = availH / (ny + (ny-1)*gapFracY)
|
||||
g.tw = g.th / tileAspect
|
||||
g.gy = g.th * gapFracY
|
||||
}
|
||||
|
||||
gw := nx*g.tw + (nx-1)*g.gx
|
||||
gh := ny*g.th + (ny-1)*g.gy
|
||||
g.ox = (float32(w) - gw) / 2
|
||||
g.oy = hud + (float32(h)-hud-gh)/2
|
||||
return g
|
||||
}
|
||||
|
||||
func (r *Renderer) drawField(w, h int32, hud float32, now time.Time) {
|
||||
r.grid.Refresh(r.state)
|
||||
r.fade.Refresh(r.state, now)
|
||||
|
||||
g := layout(w, h, hud)
|
||||
live := r.state.Phase == game.PhasePlaying && !r.state.Paused
|
||||
|
||||
for y := range parameter.GamePlayIndexYMax {
|
||||
for x := range parameter.GamePlayIndexXMax {
|
||||
rect := rl.NewRectangle(
|
||||
g.ox+float32(x)*(g.tw+g.gx),
|
||||
g.oy+float32(y)*(g.th+g.gy),
|
||||
g.tw, g.th)
|
||||
r.drawTile(rect, y, x, live, now)
|
||||
}
|
||||
}
|
||||
if live {
|
||||
r.drawResetFunnel(g, now)
|
||||
}
|
||||
}
|
||||
|
||||
// drawTile renders one grid position. The player fill runs first: raylib paints
|
||||
// opaque, so the background cannot be applied after the glyphs
|
||||
func (r *Renderer) drawTile(rect rl.Rectangle, y, x int, live bool, now time.Time) {
|
||||
tl := r.grid.Tiles[y][x]
|
||||
seg := tl.NearestWall()
|
||||
|
||||
if y == r.state.PlayerState.PlayIndexY && x == r.state.PlayerState.PlayIndexX {
|
||||
rl.DrawRectangleRec(rect, toRl(render.PlayerBgFor(r.state.Status, now)))
|
||||
}
|
||||
|
||||
ring, thick := toRl(render.RingIdle), float32(ringPx)
|
||||
switch {
|
||||
case seg.Distance == 0:
|
||||
// Lane lethal at the current chord: solid body, matches the terminal
|
||||
// border fill
|
||||
ring, thick = toRl(render.WallBandColor(0)), float32(ringWallPx)
|
||||
body := ring
|
||||
body.A = wallBodyA
|
||||
rl.DrawRectangleRec(rect, body)
|
||||
case seg.Distance > 0:
|
||||
ring = toRl(render.WallBandColor(seg.Distance))
|
||||
}
|
||||
rl.DrawRectangleLinesEx(rect, thick, ring)
|
||||
|
||||
fs := fontSize(rect)
|
||||
r.drawWallTimer(rect, fs, seg, live, now)
|
||||
r.drawDistRow(rect, fs, tl, live, now)
|
||||
r.drawStrip(rect, y, x, tl)
|
||||
}
|
||||
|
||||
func fontSize(rect rl.Rectangle) int32 { return max(int32(rect.Height*0.19), 10) }
|
||||
|
||||
func drawCentered(s string, cx, y float32, fs int32, c rl.Color) {
|
||||
rl.DrawText(s, int32(cx)-rl.MeasureText(s, fs)/2, int32(y), fs, c)
|
||||
}
|
||||
|
||||
func (r *Renderer) drawWallTimer(rect rl.Rectangle, fs int32, seg game.WallSegment, live bool, now time.Time) {
|
||||
if !live {
|
||||
return
|
||||
}
|
||||
s, c := render.WallTimerText(render.TimerASCII, r.state, seg, now)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
drawCentered(s, rect.X+rect.Width/2, rect.Y+rect.Height*rowTimerY, fs, toRl(c))
|
||||
}
|
||||
|
||||
// drawDistRow draws two fixed polarity groups: nearest help, nearest threat.
|
||||
// A group whose distance equals the grid-wide nearest for its polarity blinks;
|
||||
// ties are not broken
|
||||
func (r *Renderer) drawDistRow(rect rl.Rectangle, fs int32, tl game.TileLookahead, live bool, now time.Time) {
|
||||
if tl.Positive.Distance < 0 && tl.Negative.Distance < 0 {
|
||||
return
|
||||
}
|
||||
cx, y := rect.X+rect.Width/2, rect.Y+rect.Height*rowDistY
|
||||
off := rect.Width * 0.19
|
||||
|
||||
r.drawDistGroup(cx-off, y, fs, tl.Positive,
|
||||
live && tl.Positive.Distance == r.grid.NearPos, render.BlinkPositive, now)
|
||||
r.drawDistGroup(cx+off, y, fs, tl.Negative,
|
||||
live && tl.Negative.Distance == r.grid.NearNeg, render.BlinkNegative, now)
|
||||
}
|
||||
|
||||
func (r *Renderer) drawDistGroup(cx, y float32, fs int32, it game.ItemStat, blink bool, pair [2]color.RGB, now time.Time) {
|
||||
if it.Distance < 0 {
|
||||
return
|
||||
}
|
||||
ch, c := render.TileGlyphASCII(it.Value, it.Distance)
|
||||
if blink {
|
||||
c = render.BlinkColor(pair, now)
|
||||
}
|
||||
drawCentered(string(ch)+render.DistText(it.Distance), cx, y, fs, toRl(c))
|
||||
}
|
||||
|
||||
// drawStrip renders the lane timeline: one cell per chord distance, leftmost =
|
||||
// the current chord. Items draw at arrived color (distance is positional),
|
||||
// walls keep the band gradient so ring, timer and strip agree. An emptied cell
|
||||
// with a pending burn-out flares and collapses in place
|
||||
func (r *Renderer) drawStrip(rect rl.Rectangle, y, x int, tl game.TileLookahead) {
|
||||
pad := rect.Width * 0.06
|
||||
cw := (rect.Width - 2*pad) / float32(parameter.PositionLookaheadWindow)
|
||||
cy := rect.Y + rect.Height*rowStripY
|
||||
rad := min(cw*0.42, rect.Height*0.10)
|
||||
|
||||
for d := range parameter.PositionLookaheadWindow {
|
||||
cx := rect.X + pad + (float32(d)+0.5)*cw
|
||||
if d >= tl.Window {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := tl.Cells[d]; {
|
||||
case v == types.ValueWall:
|
||||
_, c := render.TileGlyph(v, d)
|
||||
rl.DrawRectangleRec(rl.NewRectangle(cx-cw*0.4, cy-rad, cw*0.8, rad*2), toRl(c))
|
||||
|
||||
case v != types.ValueNone:
|
||||
_, c := render.TileGlyph(v, 0)
|
||||
rl.DrawCircle(int32(cx), int32(cy), rad, toRl(c))
|
||||
|
||||
default:
|
||||
fv, t, ok := r.fade.At(y, x, d)
|
||||
if !ok {
|
||||
rl.DrawCircle(int32(cx), int32(cy), rad*0.28, toRl(color.DimGray))
|
||||
continue
|
||||
}
|
||||
c := toRl(render.FadeColor(fv, t))
|
||||
c.A = uint8(255 * (1 - t)) // burn out to transparent
|
||||
rl.DrawCircle(int32(cx), int32(cy), rad*float32(1.6-1.2*t), c)
|
||||
}
|
||||
}
|
||||
|
||||
// The chord being played
|
||||
x0 := rect.X + pad
|
||||
rl.DrawLineEx(
|
||||
rl.NewVector2(x0, cy+rad*1.6), rl.NewVector2(x0+cw, cy+rad*1.6),
|
||||
1.5, toRl(color.CoolSilver))
|
||||
}
|
||||
|
||||
// drawResetFunnel renders the pending row-reset countdown in the gap adjacent
|
||||
// to the base-row tile of the player lane. Orientation follows the return
|
||||
// direction (fall after a jump, rise after a slide)
|
||||
func (r *Renderer) drawResetFunnel(g tileGeom, now time.Time) {
|
||||
p := r.state.PlayerState
|
||||
if p.ResetTime.IsZero() || p.PlayIndexY == p.BaseIndexY {
|
||||
return
|
||||
}
|
||||
|
||||
baseY := g.oy + float32(p.BaseIndexY)*(g.th+g.gy)
|
||||
laneX := g.ox + float32(p.PlayIndexX)*(g.tw+g.gx)
|
||||
|
||||
arrow, gapY := "v", baseY-g.gy
|
||||
if p.PlayIndexY > p.BaseIndexY {
|
||||
arrow, gapY = "^", baseY+g.th
|
||||
}
|
||||
|
||||
fs := max(int32(g.gy*funnelFrac), funnelFontMin)
|
||||
s := fmt.Sprintf("%s %s %s", arrow, render.TimerText(p.ResetTime.Sub(now)), arrow)
|
||||
drawCentered(s, laneX+g.tw/2, gapY+(g.gy-float32(fs))/2, fs, rl.White)
|
||||
}
|
||||
|
||||
// --- Phase overlays ---
|
||||
|
||||
// drawLevelClear renders expanding hue-cycled rings with the clear banner
|
||||
func (r *Renderer) drawLevelClear(w, h int32, now time.Time) {
|
||||
el := now.Sub(r.state.PhaseStart).Seconds()
|
||||
cx, cy := w/2, h/2
|
||||
maxR := math.Hypot(float64(cx), float64(cy))
|
||||
|
||||
for i := range 24 {
|
||||
rr := float32(math.Mod(el*220+float64(i)*44, maxR))
|
||||
col := rl.ColorFromHSV(float32(math.Mod(el*120+float64(i)*15, 360)), 0.8, 1)
|
||||
col.A = 200
|
||||
rl.DrawCircleLines(cx, cy, rr, col)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("LEVEL %d CLEAR", r.state.Level)
|
||||
rl.DrawText(msg, cx-rl.MeasureText(msg, fontBanner)/2, cy-fontBanner, fontBanner, rl.White)
|
||||
sub := fmt.Sprintf("ENERGY %d", r.state.Energy)
|
||||
rl.DrawText(sub, cx-rl.MeasureText(sub, fontHUD)/2, cy+12, fontHUD, rl.LightGray)
|
||||
}
|
||||
|
||||
// drawGameOver overlays the death banner on the frozen field (the fatal wall
|
||||
// stays visible)
|
||||
func (r *Renderer) drawGameOver(w, h int32) {
|
||||
rl.DrawRectangle(0, 0, w, h, rl.NewColor(0, 0, 0, 160))
|
||||
msg := "GAME OVER"
|
||||
rl.DrawText(msg, w/2-rl.MeasureText(msg, fontBanner)/2, h/2-fontBanner, fontBanner, toRl(color.BrightRed))
|
||||
sub := fmt.Sprintf("ENERGY %d - LEVEL %d", r.state.Energy, r.state.Level)
|
||||
rl.DrawText(sub, w/2-rl.MeasureText(sub, fontHUD)/2, h/2+8, fontHUD, toRl(color.Silver))
|
||||
hint := "ENTER TO RESTART"
|
||||
rl.DrawText(hint, w/2-rl.MeasureText(hint, fontHUD)/2, h/2+34, fontHUD, toRl(color.DimSilver))
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"symph/game"
|
||||
"symph/parameter"
|
||||
)
|
||||
|
||||
// GridScan caches one frame of per-position lookahead and resolves the
|
||||
// grid-wide nearest distances. Blink membership is a grid-scope property, so
|
||||
// every position is scanned before any tile is drawn. Allocation-free: fixed
|
||||
// arrays refilled in place, shared by both frontends
|
||||
type GridScan struct {
|
||||
Tiles [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax]game.TileLookahead
|
||||
NearPos int // grid-wide nearest positive distance; -1 when none
|
||||
NearNeg int // grid-wide nearest negative distance; -1 when none
|
||||
}
|
||||
|
||||
func (g *GridScan) Refresh(s *game.GameState) {
|
||||
g.NearPos, g.NearNeg = -1, -1
|
||||
for y := range parameter.GamePlayIndexYMax {
|
||||
for x := range parameter.GamePlayIndexXMax {
|
||||
tl := s.ScanTile(y, x, parameter.PositionLookaheadWindow)
|
||||
g.Tiles[y][x] = tl
|
||||
if d := tl.Positive.Distance; d >= 0 && (g.NearPos < 0 || d < g.NearPos) {
|
||||
g.NearPos = d
|
||||
}
|
||||
if d := tl.Negative.Distance; d >= 0 && (g.NearNeg < 0 || d < g.NearNeg) {
|
||||
g.NearNeg = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"symph/game"
|
||||
"symph/parameter"
|
||||
"symph/types"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/color"
|
||||
)
|
||||
|
||||
// Compile-time: one density glyph per wall band
|
||||
const _ = uint(len(parameter.Density256Chars) - parameter.WallBandCount)
|
||||
|
||||
// PlayerBg is the tile-wide background of the player position — deep indigo,
|
||||
// contrasts against the black field while preserving legibility of every
|
||||
// foreground band color (yellow/orange/red) and item color
|
||||
var PlayerBg = color.RGB{R: 32, G: 40, B: 72}
|
||||
|
||||
// A held shield charge and an active Boost are field state — immunity must read
|
||||
// at the point of contact, not only in the HUD. Both fills stay dark: every
|
||||
// wall band color and item color is high-luminance and must remain legible over
|
||||
// the player tile
|
||||
var (
|
||||
PlayerShieldBg = color.RGB{R: 40, G: 72, B: 88}
|
||||
PlayerBoostBg = color.RGB{R: 64, G: 48, B: 16}
|
||||
)
|
||||
|
||||
// PlayerBgFor selects the player fill for the active status. Boost outranks
|
||||
// Shield: it is the effect absorbing the contact
|
||||
func PlayerBgFor(s game.Status, now time.Time) color.RGB {
|
||||
switch {
|
||||
case s.Boosted(now):
|
||||
return PlayerBoostBg
|
||||
case s.Shielded():
|
||||
return PlayerShieldBg
|
||||
}
|
||||
return PlayerBg
|
||||
}
|
||||
|
||||
// RingIdle is the border color of a tile with no wall in the window
|
||||
var RingIdle = color.Teal
|
||||
|
||||
// --- Wall proximity (terminal border encoding) ---
|
||||
|
||||
// wallBandColor is indexed by proximity band (0 = arrived)
|
||||
var wallBandColor = [parameter.WallBandCount]color.RGB{
|
||||
color.BrightRed, // 0: arrived
|
||||
color.Vermilion, // 1: dark orange
|
||||
color.TigerOrange, // 2: light orange
|
||||
color.Yellow, // 3: far
|
||||
}
|
||||
|
||||
// WallSolid is the arrived-wall glyph (100% density)
|
||||
var WallSolid = parameter.Density256Chars[len(parameter.Density256Chars)-1]
|
||||
|
||||
// WallBand maps a chord distance to a proximity band (0 = arrived).
|
||||
// Distances past the last band clamp to it
|
||||
func WallBand(distance int) int {
|
||||
if distance <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min((distance+parameter.WallBandSize-1)/parameter.WallBandSize, parameter.WallBandCount-1)
|
||||
}
|
||||
|
||||
// WallBandColor maps a chord distance to its proximity band color. Band 0
|
||||
// (arrived) is bright red; receding bands cool through orange to yellow.
|
||||
// Border fill is binary — a filled ring means the lane is lethal at the
|
||||
// current chord — so approach urgency rides on ring color alone
|
||||
func WallBandColor(distance int) color.RGB {
|
||||
return wallBandColor[WallBand(distance)]
|
||||
}
|
||||
|
||||
// --- Item / note visuals ---
|
||||
|
||||
// valueVisual is the appearance of a Value in the strip and distance row: the
|
||||
// distant→arrived color ramp. Both endpoints are high-luminance so a distant
|
||||
// item stays visible against the black field. Item ramps avoid the wall band
|
||||
// family (red/orange/yellow); unauthored kinds fall back to the dim
|
||||
// unknownVisual rather than eating an item slot
|
||||
type valueVisual struct {
|
||||
far, near color.RGB
|
||||
authored bool
|
||||
}
|
||||
|
||||
var valueVisuals = [types.ValueCount]valueVisual{
|
||||
types.ValueEnergy: {color.BrightCyan, color.BrightGreen, true},
|
||||
types.ValueMagnet: {color.Cornflower, color.HotMagenta, true},
|
||||
types.ValueShield: {color.CoolSilver, color.White, true},
|
||||
types.ValueBoost: {color.PaleLemon, color.Gold, true},
|
||||
}
|
||||
|
||||
var unknownVisual = valueVisual{color.DimSilver, color.Silver, true}
|
||||
|
||||
// TileGlyph resolves the strip and distance-row glyph for a Value at a given chord distance.
|
||||
// Walls follow the border band encoding so the ring, strip, and distance row agree.
|
||||
// Items use the morph lerp over their authored ramp
|
||||
func TileGlyph(v types.Value, distance int) (rune, color.RGB) {
|
||||
if v == types.ValueWall {
|
||||
return WallSolid, WallBandColor(distance)
|
||||
}
|
||||
if !v.Valid() || v == types.ValueNone {
|
||||
return 0, color.RGB{}
|
||||
}
|
||||
t := proximity(distance)
|
||||
if vis := valueVisuals[v]; vis.authored {
|
||||
return v.Glyph(), vis.far.Lerp(vis.near, t)
|
||||
}
|
||||
return parameter.UnknownChar, unknownVisual.far.Lerp(unknownVisual.near, t)
|
||||
}
|
||||
|
||||
// TileGlyphASCII resolves the glyph for a frontend limited to the 32..126 atlas:
|
||||
// the wall's density block is substituted with its authoring rune. Colors are
|
||||
// identical, so the two frontends stay in presentation parity
|
||||
func TileGlyphASCII(v types.Value, distance int) (rune, color.RGB) {
|
||||
ch, c := TileGlyph(v, distance)
|
||||
if v == types.ValueWall {
|
||||
ch = v.Glyph()
|
||||
}
|
||||
return ch, c
|
||||
}
|
||||
|
||||
// proximity maps a chord distance to [0,1]: 1 = arrived (distance<=0),
|
||||
// 0 = at or beyond MorphWindow chords away
|
||||
func proximity(distance int) float64 {
|
||||
if distance <= 0 {
|
||||
return 1
|
||||
}
|
||||
if distance >= parameter.MorphWindow {
|
||||
return 0
|
||||
}
|
||||
return 1 - float64(distance)/float64(parameter.MorphWindow)
|
||||
}
|
||||
|
||||
// --- HUD effect segment ---
|
||||
|
||||
// StatusText composes the active-effect HUD segment: the Shield charge glyph
|
||||
// and the Boost countdown. Glyphs are the ASCII authoring runes, so the string
|
||||
// is renderable in both atlases. Empty outside a running PhasePlaying — the
|
||||
// deadlines are frozen there and a ticking countdown would be a lie
|
||||
func StatusText(s *game.GameState, now time.Time) string {
|
||||
if s.Phase != game.PhasePlaying || s.Paused {
|
||||
return ""
|
||||
}
|
||||
out := make([]rune, 0, 8)
|
||||
if s.Status.Shielded() {
|
||||
out = append(out, types.ValueShield.Glyph())
|
||||
}
|
||||
if s.Status.Boosted(now) {
|
||||
if len(out) > 0 {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
out = append(out, types.ValueBoost.Glyph(), ' ')
|
||||
out = append(out, []rune(TimerText(s.Status.BoostRemaining(now)))...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// StatusColor tints the effect segment with the arrived color of the expiring
|
||||
// effect — the Boost when one runs, else the Shield
|
||||
func StatusColor(s *game.GameState, now time.Time) color.RGB {
|
||||
if rem := s.Status.BoostRemaining(now); rem > 0 {
|
||||
if rem <= parameter.BoostWarnRemaining {
|
||||
return BlinkColor(BlinkNegative, now)
|
||||
}
|
||||
_, c := TileGlyph(types.ValueBoost, 0)
|
||||
return c
|
||||
}
|
||||
_, c := TileGlyph(types.ValueShield, 0)
|
||||
return c
|
||||
}
|
||||
|
||||
// --- Consumption burn-out ---
|
||||
|
||||
// FadeColor is the burn-out color of a consumed item at progress t in
|
||||
// [0,1): a white flash, then a sink into the field. Distinguishes consumption
|
||||
// from the cell scrolling out from under the strip
|
||||
func FadeColor(v types.Value, t float64) color.RGB {
|
||||
_, base := TileGlyph(v, 0)
|
||||
if t < parameter.ItemFlashFraction {
|
||||
return color.White.Lerp(base, t/parameter.ItemFlashFraction)
|
||||
}
|
||||
u := (t - parameter.ItemFlashFraction) / (1 - parameter.ItemFlashFraction)
|
||||
return base.Lerp(color.Black, u)
|
||||
}
|
||||
|
||||
// FadeVisual adds the glyph collapse for the terminal strip: the item
|
||||
// glyph holds through the flash, then decays into the rail cell
|
||||
func FadeVisual(v types.Value, t float64) (rune, color.RGB) {
|
||||
glyph, _ := TileGlyph(v, 0)
|
||||
if t >= parameter.ItemFlashFraction {
|
||||
u := (t - parameter.ItemFlashFraction) / (1 - parameter.ItemFlashFraction)
|
||||
if i := int(u * float64(len(parameter.ItemFadeChars)+1)); i > 0 {
|
||||
glyph = parameter.ItemFadeChars[min(i-1, len(parameter.ItemFadeChars)-1)]
|
||||
}
|
||||
}
|
||||
return glyph, FadeColor(v, t)
|
||||
}
|
||||
|
||||
// --- Nearest-occurrence blink ---
|
||||
|
||||
// Blink pairs for the distance-row group of the tile(s) holding the grid-wide
|
||||
// nearest occurrence of each polarity. Both endpoints are high-luminance: the
|
||||
// cue must read as motion, not as a dip to the background
|
||||
var (
|
||||
BlinkPositive = [2]color.RGB{color.White, color.BrightGreen}
|
||||
BlinkNegative = [2]color.RGB{color.Yellow, color.BrightRed}
|
||||
)
|
||||
|
||||
// BlinkColor selects the phase color of a pair. Phase is derived from the
|
||||
// wall clock, not from PlayDeltaZ: the cue must stay legible when the beat
|
||||
// tightens at higher levels
|
||||
func BlinkColor(pair [2]color.RGB, now time.Time) color.RGB {
|
||||
return pair[(now.UnixNano()/int64(parameter.RenderBlinkPeriod))&1]
|
||||
}
|
||||
|
||||
// --- Text composition (shared by both frontends) ---
|
||||
|
||||
// TimerGlyphs is the wall-countdown affix set. The raylib default font
|
||||
// atlas covers 32..126 only, so it substitutes TimerASCII
|
||||
type TimerGlyphs struct{ Inbound, Clear, Open, Trap rune }
|
||||
|
||||
var (
|
||||
TimerUnicode = TimerGlyphs{
|
||||
parameter.TimerInboundPrefix, parameter.TimerClearPrefix,
|
||||
parameter.TimerOpenSuffix, parameter.TimerTrapSuffix,
|
||||
}
|
||||
TimerASCII = TimerGlyphs{'v', '^', '+', '!'}
|
||||
)
|
||||
|
||||
// WallTimerText composes the wall countdown for a segment. Inbound
|
||||
// counts down to closure, clear counts down to the reopen chord. The trap
|
||||
// marker is not arrival-only: an inbound wall whose reopen gap is a trap is
|
||||
// flagged on approach. Returns "" when no wall is in the window
|
||||
func WallTimerText(g TimerGlyphs, s *game.GameState, seg game.WallSegment, now time.Time) (string, color.RGB) {
|
||||
if seg.Distance < 0 {
|
||||
return "", color.RGB{}
|
||||
}
|
||||
|
||||
out := make([]rune, 0, 8)
|
||||
fg := WallBandColor(seg.Distance)
|
||||
|
||||
if seg.Distance == 0 {
|
||||
out = append(out, g.Clear)
|
||||
out = append(out, []rune(TimerText(s.TimeToChordDistance(seg.Run, now)))...)
|
||||
if seg.Open {
|
||||
out = append(out, g.Open)
|
||||
}
|
||||
fg = color.White
|
||||
} else {
|
||||
out = append(out, g.Inbound)
|
||||
out = append(out, []rune(TimerText(s.TimeToChordDistance(seg.Distance, now)))...)
|
||||
}
|
||||
|
||||
if seg.Next && seg.Gap <= parameter.TrapGapMax {
|
||||
out = append(out, g.Trap)
|
||||
}
|
||||
return string(out), fg
|
||||
}
|
||||
|
||||
// TimerText formats a countdown as "S.s", clamped to
|
||||
// [0.0, 9.9]. Window * PlayDeltaZ stays under 10s at all levels; the clamp is a guard
|
||||
func TimerText(d time.Duration) string {
|
||||
s := d.Seconds()
|
||||
switch {
|
||||
case s < 0:
|
||||
s = 0
|
||||
case s > 9.9:
|
||||
s = 9.9
|
||||
}
|
||||
return fmt.Sprintf("%.1f", s)
|
||||
}
|
||||
|
||||
// DistText formats a chord-distance into RenderDistDigits columns
|
||||
func DistText(d int) string {
|
||||
if d < 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", min(d, 99))
|
||||
}
|
||||
Reference in New Issue
Block a user