v0.1.0 initial commit
This commit is contained in:
@@ -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