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