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