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

259 lines
6.1 KiB
Go

package game
import (
"fmt"
"math/rand/v2"
"strings"
"symph/parameter"
"symph/types"
)
// patternDef is an authored gameplay segment. Patterns are hand-built to stay
// fair: every chord leaves a reachable safe cell, hazards are telegraphed by
// preceding pickups. minLevel gates entry into the selection pool
type patternDef struct {
minLevel int
chords []string
}
var patternDefs = []patternDef{
// --- Level 1: pickup lines, no hazards ---
{1, []string{ // center run
"...|.*.|...",
"...|.*.|...",
"...|.*.|...",
}},
{1, []string{ // left lane run
"...|*..|...",
"...|*..|...",
"...|*..|...",
}},
{1, []string{ // right lane run
"...|..*|...",
"...|..*|...",
"...|..*|...",
}},
{1, []string{ // jump arc
"...|.*.|...",
".*.|...|...",
".*.|...|...",
"...|.*.|...",
}},
{1, []string{ // slide dip
"...|.*.|...",
"...|...|.*.",
"...|...|.*.",
"...|.*.|...",
}},
{1, []string{ // lane sweep L→R
"...|*..|...",
"...|.*.|...",
"...|..*|...",
}},
// --- Level 2: single telegraphed obstacles ---
{2, []string{ // hop wall: energy above hazard
"...|.*.|...",
".*.|.#.|...",
"...|.*.|...",
}},
{2, []string{ // duck wall: energy below hazard
"...|.*.|...",
"...|.#.|.*.",
"...|.*.|...",
}},
{2, []string{ // side pinch: hold center
"...|.*.|...",
"...|#.#|...",
"...|#.#|...",
"...|.*.|...",
}},
{2, []string{ // forced left lane
"...|*..|...",
"...|*.#|...",
"...|*.#|...",
"...|.*.|...",
}},
{2, []string{ // magnet lure
"...|.*.|...",
"...|.M.|...",
"*.*|...|*.*",
"...|.*.|...",
}},
{2, []string{ // shield cache: armor, then the wall that spends it
"...|.*.|...",
"...|.S.|...",
"...|.#.|...",
"...|.*.|...",
}},
{2, []string{ // boost dash — tempo alone, no hazards
"...|.B.|...",
"...|.*.|...",
"...|*..|...",
"...|..*|...",
"...|*..|...",
"...|.*.|...",
}},
// --- Level 3+: combinations ---
{3, []string{ // hop then duck
"...|.*.|...",
".*.|.#.|...",
"...|.*.|...",
"...|.#.|.*.",
"...|.*.|...",
}},
{3, []string{ // tunnel: mid row only (Down cancels a stray jump in time)
"...|.*.|...",
"###|.S.|###",
"###|.*.|###",
"###|.*.|###",
"...|.*.|...",
}},
{3, []string{ // top weave R→L over walls (single jump spans it at base PDZ)
"...|.*.|...",
"..*|.##|...",
".*.|#.#|...",
"*..|##.|...",
"...|.*.|...",
}},
{3, []string{ // pillar dodge: center blocked, side lanes rewarded
"...|.*.|...",
".#.|*#*|.#.",
"...|.*.|...",
}},
{3, []string{ // trap lane: center reopens for a single chord, then shuts again. Exercises the TimerTrapSuffix marker
"...|.*.|...",
"...|.#.|...",
"...|.#.|...",
".*.|...|...",
"...|.#.|...",
"...|.#.|...",
"...|.*.|...",
}},
{3, []string{ // boost run: hazard-free corridor — the double tempo turns the lane hops into the skill test
"...|.B.|...",
"...|.*.|...",
"...|*.*|...",
"...|.*.|...",
"...|*.*|...",
"...|.*.|...",
}},
{3, []string{ // boost gauntlet — mid-center is open at every chord
"...|.B.|...",
"...|.*.|...",
"#.#|.*.|#.#",
"*.*|.*.|*.*",
"#.#|.*.|#.#",
"...|.*.|...",
}},
{4, []string{ // armored gauntlet: a walking wall run. Weave the mid row or jump it; the shield covers one mistimed lane change
"...|.S.|...",
"...|.*.|...",
"...|##.|...",
"...|.##|...",
"...|#.#|...",
"...|.*.|...",
}},
}
// pattern is the parsed, playable form
type pattern struct {
minLevel int
chords []types.Chord
}
var patterns []pattern
func init() {
patterns = make([]pattern, len(patternDefs))
for i, d := range patternDefs {
p := pattern{minLevel: d.minLevel, chords: make([]types.Chord, len(d.chords))}
for j, s := range d.chords {
p.chords[j] = parseChord(s)
}
patterns[i] = p
}
}
// parseChord decodes a "TTT|MMM|BBB" literal; panics on malformed input or on
// a kind the engine cannot resolve (author error, caught at process start).
// Byte indexing is sound: types asserts every glyph is ASCII
func parseChord(s string) types.Chord {
rows := strings.Split(s, "|")
if len(rows) != parameter.GamePlayIndexYMax {
panic(fmt.Sprintf("pattern chord %q: want %d rows", s, parameter.GamePlayIndexYMax))
}
var c types.Chord
for y, row := range rows {
if len(row) != parameter.GamePlayIndexXMax {
panic(fmt.Sprintf("pattern row %q: want %d cells", row, parameter.GamePlayIndexXMax))
}
for x, r := range row {
v, ok := types.ValueByGlyph(r)
if !ok {
panic(fmt.Sprintf("pattern cell %q: unknown glyph", string(r)))
}
if v != types.ValueNone && resolvers[v] == nil {
panic(fmt.Sprintf("pattern cell %q: %s has no engine resolver", string(r), v))
}
c.Notes[y][x] = types.Note{Value: v}
}
}
return c
}
// newSong builds a level: intro rest, pattern chains separated by rest gaps
// that shrink with level, outro rest. The pool widens as level unlocks higher
// minLevel entries; chains lengthen with level
func newSong(level int) *types.Song {
length := min(
parameter.SongBaseLength+(level-1)*parameter.SongLengthPerLevel,
parameter.GamePlayIndexZMax,
)
pool := make([]pattern, 0, len(patterns))
for _, p := range patterns {
if p.minLevel <= level {
pool = append(pool, p)
}
}
chords := make([]types.Chord, 0, length)
chords = append(chords, make([]types.Chord, parameter.GenIntroRest)...)
budget := length - parameter.GenOutroRest
rest := max(parameter.GenRestBase-(level-1), parameter.GenRestMin)
build:
for {
chain := parameter.GenChainBase +
rand.IntN(min(level, parameter.GenChainMax-parameter.GenChainBase)+1)
for range chain {
p := pool[rand.IntN(len(pool))]
if len(chords)+len(p.chords) > budget {
break build
}
// Value-copy append: runtime consumption mutates the Song,
// never the templates
chords = append(chords, p.chords...)
if rand.Float64() < parameter.GenBreatherProbability && len(chords) < budget {
chords = append(chords, types.Chord{})
}
}
if len(chords)+rest > budget {
break
}
chords = append(chords, make([]types.Chord, rest)...)
}
// Pad to exact length; tail padding doubles as outro rest
for len(chords) < length {
chords = append(chords, types.Chord{})
}
return &types.Song{Chords: chords}
}