259 lines
6.1 KiB
Go
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}
|
|
}
|