M12: ljud, inställningar och release v0.1.0
All checks were successful
release / build-release (push) Successful in 1m35s

tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang
(en per station via melody-fältet eller banans id), ankomstjingel,
ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och
nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal,
vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter
utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har
huvudvolym × effekter/musik och byter inte slinga i onödan.
Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas
om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt.
Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival
och inställningar. Docs, wiki och skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:31:38 +01:00
parent 36a45563ec
commit 86f8130735
47 changed files with 800 additions and 107 deletions

View File

@@ -291,6 +291,22 @@ func main() {
planeImg := image.NewNRGBA(image.Rect(0, 0, 32, 12))
plane(canvas{planeImg, 0, 0})
save(filepath.Join(out, "plane.png"), planeImg)
iconImg := image.NewNRGBA(image.Rect(0, 0, 32, 32))
icon(canvas{iconImg, 0, 0})
save(filepath.Join(out, "icon.png"), iconImg)
}
// icon är fönsterikonen (32×32): ett tågfront på mörk platta.
func icon(c canvas) {
c.rect(0, 0, 32, 32, rgb(38, 43, 68))
c.rect(6, 4, 20, 24, rgb(236, 240, 245)) // front
c.rect(8, 7, 16, 8, rgb(90, 140, 220)) // vindruta
c.rect(6, 17, 20, 3, rgb(214, 60, 60)) // rand
c.rect(9, 22, 4, 3, rgb(255, 230, 120)) // strålkastare
c.rect(19, 22, 4, 3, rgb(255, 230, 120)) // strålkastare
c.rect(4, 28, 24, 2, rgb(150, 160, 175)) // underrede
c.rect(13, 1, 6, 3, rgb(150, 160, 175)) // strömavtagare
}
// plane är flygplanet i slutscenen (32×12, nosen åt höger).

View File

@@ -1,12 +1,13 @@
// Genererar placeholder-ljud tills riktiga inspelningar finns:
// Genererar spelets ljud – syntetiserade, deterministiska och fria att
// använda, tills riktiga inspelningar finns:
//
// go run ./tools/gensound
//
// Skriver resources/sound/melody-approach.wav (en kort pentatonisk
// avgångsmelodi i japansk stationsstil, spelas när tåget är på väg in) och
// resources/sound/door-chime.wav (dörrsignal strax före avgång). 16-bit
// mono 44,1 kHz, syntetiserade med enkla vågformer så att allt är
// deterministiskt.
// Skriver 16-bit mono 44,1 kHz WAV till resources/sound/: fyra
// stationsmelodier (avgångsmelodier i japansk stationsstil, en per
// station), ankomstjingel, utropssignal, dörrsignal, spärrens pip och
// nekande, kiosk, toalett, lärt ord, bekräfta/avbryt, byte, vinst, förlust,
// personal, flygplan, bakgrundssorl och kartans musikslinga.
package main
import (
@@ -24,20 +25,54 @@ type note struct {
dur float64 // sekunder
}
// Pentatonisk melodi (C-dur-pentatonik), två takter.
// Toner (Hz).
const (
c5, d5, e5, f5, g5, a5, b5 = 523.25, 587.33, 659.25, 698.46, 783.99, 880.00, 987.77
c6, d6, e6, g6, a6 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00
g4, a4, b4, e4, f4 = 392.00, 440.00, 493.88, 329.63, 349.23
)
// Ankomstjingel (C-dur-pentatonik), spelas när tåget är på väg in.
var approachMelody = []note{
{523.25, 0.18}, {587.33, 0.18}, {659.25, 0.18}, {783.99, 0.36},
{880.00, 0.18}, {783.99, 0.18}, {659.25, 0.18}, {523.25, 0.36},
{c5, 0.18}, {d5, 0.18}, {e5, 0.18}, {g5, 0.36},
{a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {c5, 0.36},
{0, 0.12},
{659.25, 0.18}, {783.99, 0.18}, {880.00, 0.18}, {1046.50, 0.54},
{e5, 0.18}, {g5, 0.18}, {a5, 0.18}, {c6, 0.54},
}
// Stationsmelodier: fyra olika avgångsmelodier (発車メロディ-stil, egna
// kompositioner), ca 2,5 s, spelas innan dörrarna stängs.
var stationMelodies = map[string][]note{
"a": {{g5, 0.15}, {e5, 0.15}, {g5, 0.15}, {c6, 0.3}, {b5, 0.15}, {g5, 0.15}, {a5, 0.3}, {0, 0.1}, {g5, 0.15}, {e5, 0.15}, {d5, 0.15}, {c5, 0.6}},
"b": {{e5, 0.2}, {g5, 0.2}, {a5, 0.2}, {g5, 0.2}, {e5, 0.4}, {0, 0.1}, {d5, 0.2}, {e5, 0.2}, {g5, 0.2}, {e5, 0.2}, {d5, 0.2}, {c5, 0.6}},
"c": {{c6, 0.12}, {b5, 0.12}, {a5, 0.12}, {g5, 0.24}, {a5, 0.12}, {g5, 0.12}, {e5, 0.24}, {0, 0.08}, {g5, 0.12}, {a5, 0.12}, {c6, 0.12}, {d6, 0.24}, {c6, 0.6}},
"d": {{a5, 0.18}, {c6, 0.18}, {a5, 0.18}, {g5, 0.36}, {e5, 0.18}, {g5, 0.18}, {a5, 0.36}, {0, 0.1}, {c6, 0.18}, {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {g5, 0.6}},
}
// Dörrsignal: två toner växelvis.
var doorChime = []note{
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.14},
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.28},
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.14},
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.28},
}
// Utropssignal ("ding-dong") före utrop.
var announce = []note{{e6, 0.25}, {c6, 0.45}}
// Korta effekter.
var (
learnJingle = []note{{c6, 0.09}, {e6, 0.09}, {g6, 0.22}}
confirmBlip = []note{{a5, 0.05}, {c6, 0.07}}
cancelBlip = []note{{e5, 0.06}, {c5, 0.08}}
transferJingle = []note{{g5, 0.12}, {c6, 0.12}, {e6, 0.12}, {g6, 0.3}}
winFanfare = []note{{c5, 0.14}, {e5, 0.14}, {g5, 0.14}, {c6, 0.4}, {0, 0.06}, {g5, 0.12}, {c6, 0.5}}
loseSting = []note{{e5, 0.22}, {d5, 0.22}, {c5, 0.5}}
kioskCoin = []note{{b5, 0.05}, {e6, 0.05}, {0, 0.04}, {g6, 0.12}}
staffTone = []note{{a4, 0.18}, {0, 0.08}, {a4, 0.18}, {0, 0.12}, {e5, 0.3}}
gateBeep = []note{{a6, 0.09}}
gateDeny = []note{{e4, 0.12}, {0, 0.04}, {e4, 0.12}, {0, 0.04}, {e4, 0.14}}
)
// render syntetiserar toner med sinus + överton, attack/release-envelope.
func render(notes []note, attack, release float64, overtone float64) []int16 {
var out []int16
for _, n := range notes {
@@ -63,6 +98,133 @@ func render(notes []note, attack, release float64, overtone float64) []int16 {
return out
}
// bell syntetiserar klockliknande toner: grundton + två övertoner som dör
// ut snabbare, exponentiell avklingning – stationsmelodiernas klang.
func bell(notes []note) []int16 {
var out []int16
for _, n := range notes {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
if n.freq == 0 {
out = append(out, 0)
continue
}
t := float64(i) / rate
env := math.Exp(-3.5 * t / n.dur)
if t < 0.004 {
env *= t / 0.004
}
if rem := n.dur - t; rem < 0.02 {
env *= rem / 0.02
}
v := math.Sin(2*math.Pi*n.freq*t) +
0.45*math.Exp(-6*t/n.dur)*math.Sin(2*math.Pi*2*n.freq*t) +
0.2*math.Exp(-9*t/n.dur)*math.Sin(2*math.Pi*3*n.freq*t)
out = append(out, int16(v/1.65*env*0.55*32767))
}
}
return out
}
// square syntetiserar en råare, fyrkantsliknande ton (spärrens nekande).
func square(notes []note) []int16 {
var out []int16
for _, n := range notes {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
if n.freq == 0 {
out = append(out, 0)
continue
}
t := float64(i) / rate
v := 0.0
for h := 1.0; h <= 7; h += 2 { // udda övertoner
v += math.Sin(2*math.Pi*n.freq*h*t) / h
}
env := 1.0
if rem := n.dur - t; rem < 0.02 {
env = rem / 0.02
}
out = append(out, int16(v/1.6*env*0.35*32767))
}
}
return out
}
// lcg är en deterministisk brusgenerator.
type lcg uint32
func (l *lcg) next() float64 {
*l = *l*1664525 + 1013904223
return float64(*l>>8)/float64(1<<24)*2 - 1
}
// noiseSweep är filtrerat brus med envelope och filtersvep: toalettspolning
// (fallande) och flygplan (stigande och fallande).
func noiseSweep(seconds, lpFrom, lpTo float64, env func(t float64) float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var r lcg = 777
lp := 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
f := t / seconds
k := lpFrom + (lpTo-lpFrom)*f // filterkoefficient (0..1): högre = ljusare
lp += (r.next() - lp) * k
out[i] = int16(lp * env(t) * 0.7 * 32767)
}
return out
}
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
func chatter(seconds float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var r lcg = 12345
lp := 0.0
env, envTarget, envT := 0.3, 0.3, 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
if t >= envT { // ny "stavelse" var 120–250 ms
envTarget = 0.1 + 0.6*math.Abs(r.next())
envT = t + 0.12 + 0.13*math.Abs(r.next())
}
env += (envTarget - env) * 0.002
lp += (r.next() - lp) * 0.08 // lågpass ≈ 600 Hz
v := lp * env * 0.6
fade := math.Min(1, math.Min(t, seconds-t)/0.2) // sömlös loop
out[i] = int16(v * fade * 32767)
}
return out
}
// mapMusic är kartans musikslinga: ett långsamt arpeggio i C-dur-pentatonik
// över två ackord, mjuka toner, loopbar (8 s).
func mapMusic() []int16 {
pattern := []note{
{c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {a5, 0.5}, {g5, 0.5}, {e5, 0.5}, {d5, 0.5}, {e5, 0.5},
{a4, 0.5}, {e5, 0.5}, {c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {c5, 0.5}, {d5, 0.5}, {g4, 0.5},
}
var out []int16
for _, n := range pattern {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
t := float64(i) / rate
env := math.Exp(-2.2*t/n.dur) * math.Min(1, t/0.01)
v := math.Sin(2*math.Pi*n.freq*t) + 0.3*math.Sin(2*math.Pi*2*n.freq*t) + 0.15*math.Sin(2*math.Pi*n.freq/2*t)
out = append(out, int16(v/1.45*env*0.3*32767))
}
}
// låt tonerna ringa in i varandra: mjuk in-/utfasning i kanterna för loopen
for i := 0; i < 400; i++ {
f := float64(i) / 400
out[i] = int16(float64(out[i]) * f)
out[len(out)-1-i] = int16(float64(out[len(out)-1-i]) * f)
}
return out
}
func writeWAV(path string, pcm []int16) error {
f, err := os.Create(path)
if err != nil {
@@ -83,39 +245,35 @@ func writeWAV(path string, pcm []int16) error {
return binary.Write(f, binary.LittleEndian, pcm)
}
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
func chatter(seconds float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var lcg uint32 = 12345
next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 }
lp := 0.0
env, envTarget, envT := 0.3, 0.3, 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
// ny "stavelse" var 120–250 ms
if t >= envT {
envTarget = 0.1 + 0.6*math.Abs(next())
envT = t + 0.12 + 0.13*math.Abs(next())
}
env += (envTarget - env) * 0.002
lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz
v := lp * env * 0.6
// mjuk in-/utfasning för sömlös loop
fade := math.Min(1, math.Min(t, seconds-t)/0.2)
out[i] = int16(v * fade * 32767)
}
return out
}
func main() {
out := filepath.Join(repoRoot(), "resources", "sound")
must(os.MkdirAll(out, 0o755))
must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3)))
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1)))
must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6)))
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav")
clips := map[string][]int16{
"melody-approach": render(approachMelody, 0.01, 0.06, 0.3),
"door-chime": render(doorChime, 0.005, 0.04, 0.1),
"announce": bell(announce),
"gate-beep": render(gateBeep, 0.003, 0.02, 0.0),
"gate-deny": square(gateDeny),
"kiosk": render(kioskCoin, 0.003, 0.03, 0.5),
"toilet": noiseSweep(1.1, 0.35, 0.05, func(t float64) float64 { return math.Min(1, t/0.05) * math.Max(0, 1-t/1.1) }),
"learn": bell(learnJingle),
"confirm": render(confirmBlip, 0.003, 0.03, 0.2),
"cancel": render(cancelBlip, 0.003, 0.03, 0.2),
"transfer": bell(transferJingle),
"win": render(winFanfare, 0.005, 0.05, 0.4),
"lose": render(loseSting, 0.01, 0.08, 0.2),
"staff": square(staffTone),
"plane": noiseSweep(3.0, 0.03, 0.4, func(t float64) float64 { return math.Sin(math.Pi * t / 3.0) }),
"chatter": chatter(6),
"music-map": mapMusic(),
}
for id, m := range stationMelodies {
clips["melody-"+id] = bell(m)
}
for name, pcm := range clips {
must(writeWAV(filepath.Join(out, name+".wav"), pcm))
}
fmt.Printf("skrev %d ljud till %s\n", len(clips), out)
}
func repoRoot() string {

View File

@@ -168,6 +168,7 @@ func buildShots(levelsDir string) error {
{"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}},
{"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}},
{"", "wait 10; shot $OUT/endless.png; quit", []string{"-endless", "-seed", "7", "-mode", "list", "-save", "$DEMO"}},
{"", "wait 10; press s; wait 5; press down; wait 3; shot $OUT/settings.png; quit", []string{"-save", "$DEMO"}},
{"005-ueno", toPlatform5 + "; " + toGateBack + "; wait 5; push " + pushOut + " 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}},
}
return nil