v0.1.0 initial commit
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user