Files
2026-07-15 01:28:56 -04:00

321 lines
9.9 KiB
Go

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