diff --git a/alg/floatgeom/point.go b/alg/floatgeom/point.go index d44d976a..7335f1ea 100644 --- a/alg/floatgeom/point.go +++ b/alg/floatgeom/point.go @@ -398,7 +398,6 @@ func (p Point3) ToRect(span float64) Rect3 { // ProjectX projects the Point3 onto the x axis, removing it's // x component and returning a Point2 -// todo: I'm not sure about this (these) function name func (p Point3) ProjectX() Point2 { return Point2{p[1], p[2]} } diff --git a/alg/floatgeom/point_test.go b/alg/floatgeom/point_test.go index a1a0d1a0..49617207 100644 --- a/alg/floatgeom/point_test.go +++ b/alg/floatgeom/point_test.go @@ -265,7 +265,6 @@ func TestPointAccess(t *testing.T) { // Pattern here: there's a set of input pairs here // each test takes these and has expected outputs for each pair index. var ( - // Todo: add more test cases pt3cases = []struct{ x1, y1, z1, x2, y2, z2 float64 }{ {0, 0, 0, 1, 1, 1}, } diff --git a/alg/intgeom/point.go b/alg/intgeom/point.go index 1025bc3d..d8038baf 100644 --- a/alg/intgeom/point.go +++ b/alg/intgeom/point.go @@ -290,7 +290,6 @@ func (p Point3) ToRect(span int) Rect3 { // ProjectX projects the Point3 onto the x axis, removing it's // x component and returning a Point2 -// todo: I'm not sure about this (these) function name func (p Point3) ProjectX() Point2 { return Point2{p[1], p[2]} } diff --git a/alg/intgeom/point_test.go b/alg/intgeom/point_test.go index e96441be..6e28860d 100644 --- a/alg/intgeom/point_test.go +++ b/alg/intgeom/point_test.go @@ -173,7 +173,6 @@ func TestPointAccess(t *testing.T) { // Pattern here: there's a set of input pairs here // each test takes these and has expected outputs for each pair index. var ( - // Todo: add more test cases pt3cases = []struct{ x1, y1, z1, x2, y2, z2 int }{ {0, 0, 0, 1, 1, 1}, } diff --git a/audio/audio.go b/audio/audio.go index f22107dd..7fdcf366 100644 --- a/audio/audio.go +++ b/audio/audio.go @@ -3,8 +3,8 @@ package audio import ( "fmt" - "github.com/200sc/klangsynthese/audio" - "github.com/200sc/klangsynthese/font" + "github.com/oakmound/oak/v3/audio/font" + "github.com/oakmound/oak/v3/audio/klang" "github.com/oakmound/oak/v3/oakerr" ) @@ -12,7 +12,7 @@ import ( // required to filter it through a sound font. type Audio struct { *font.Audio - toStop audio.Audio + toStop klang.Audio X, Y *float64 setVolume int32 } @@ -76,22 +76,22 @@ func (a *Audio) Stop() error { } // Copy returns a copy of the audio -func (a *Audio) Copy() (audio.Audio, error) { +func (a *Audio) Copy() (klang.Audio, error) { a2, err := a.Audio.Copy() if err != nil { return nil, err } - return New(a.Audio.Font, a2.(audio.FullAudio), a.X, a.Y), nil + return New(a.Audio.Font, a2.(klang.FullAudio), a.X, a.Y), nil } // MustCopy acts like Copy, but panics on an error. -func (a *Audio) MustCopy() audio.Audio { - return New(a.Audio.Font, a.Audio.MustCopy().(audio.FullAudio), a.X, a.Y) +func (a *Audio) MustCopy() klang.Audio { + return New(a.Audio.Font, a.Audio.MustCopy().(klang.FullAudio), a.X, a.Y) } // Filter returns the audio with some set of filters applied to it. -func (a *Audio) Filter(fs ...audio.Filter) (audio.Audio, error) { - var ad audio.Audio = a +func (a *Audio) Filter(fs ...klang.Filter) (klang.Audio, error) { + var ad klang.Audio = a var err, consErr error for _, f := range fs { ad, err = f.Apply(ad) @@ -107,7 +107,7 @@ func (a *Audio) Filter(fs ...audio.Filter) (audio.Audio, error) { } // MustFilter acts like Filter but ignores errors. -func (a *Audio) MustFilter(fs ...audio.Filter) audio.Audio { +func (a *Audio) MustFilter(fs ...klang.Filter) klang.Audio { ad, _ := a.Filter(fs...) return ad } diff --git a/audio/audio_test.go b/audio/audio_test.go index 901d90ee..e96fbcea 100644 --- a/audio/audio_test.go +++ b/audio/audio_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/200sc/klangsynthese/audio/filter" - "github.com/200sc/klangsynthese/synth" + "github.com/oakmound/oak/v3/audio/klang/filter" + "github.com/oakmound/oak/v3/audio/synth" ) func TestAudioFuncs(t *testing.T) { @@ -14,6 +14,10 @@ func TestAudioFuncs(t *testing.T) { t.Fatalf("unexpected error: %v", err) } a := New(DefaultFont, kla.(Data)) + err = a.SetVolume(0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } err = <-a.Play() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -54,4 +58,9 @@ func TestAudioFuncs(t *testing.T) { a.Play() time.Sleep(a.PlayLength()) // Assert yet quieter audio is playing + a.SetVolume(-2000) + a.Play() + time.Sleep(a.PlayLength()) + // Assert yet quieter audio is playing + } diff --git a/audio/channelManager.go b/audio/channelManager.go deleted file mode 100644 index 64cfb6a2..00000000 --- a/audio/channelManager.go +++ /dev/null @@ -1,54 +0,0 @@ -package audio - -import ( - "github.com/200sc/klangsynthese/font" - "github.com/oakmound/oak/v3/alg/range/intrange" -) - -// A ChannelManager can create audio channels that won't be stopped at scene end, -// but can be stopped at any time by calling Close on the manager. -type ChannelManager struct { - quitCh chan struct{} - Font *font.Font -} - -// NewChannelManager creates a channel manager whose Def functions will use the -// given font. -func NewChannelManager(f *font.Font) *ChannelManager { - return &ChannelManager{ - quitCh: make(chan struct{}), - Font: f, - } -} - -// DefChannel creates an audio channel using the manager's Font. -func (cm *ChannelManager) DefChannel(freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return getChannel(cm.Font, freq, cm.quitCh, fileNames...) -} - -// DefActiveChannel creates an active channel using the manager's font. -func (cm *ChannelManager) DefActiveChannel(freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return getActiveChannel(cm.Font, freq, cm.quitCh, fileNames...) -} - -// GetChannel creates a channel using the given font. -func (cm *ChannelManager) GetChannel(f *font.Font, freq intrange.Range, - fileNames ...string) (chan ChannelSignal, error) { - - return getChannel(f, freq, cm.quitCh, fileNames...) -} - -// GetActiveChannel creates an active channel using the given font. -func (cm *ChannelManager) GetActiveChannel(f *font.Font, freq intrange.Range, - fileNames ...string) (chan ChannelSignal, error) { - - return getActiveChannel(f, freq, cm.quitCh, fileNames...) -} - -// Close closes the manager's internal channel handling audio channels. This will -// prevent further audio from being played via any of those channels, and their spawned -// routines will return. As Close does close a channel, it should not be called multiple -// times. -func (cm *ChannelManager) Close() { - close(cm.quitCh) -} diff --git a/audio/channel_test.go b/audio/channel_test.go deleted file mode 100644 index 633b6d09..00000000 --- a/audio/channel_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package audio - -import ( - "testing" - "time" - - "github.com/oakmound/oak/v3/alg/range/intrange" -) - -func TestChannels(t *testing.T) { - _, err := DefaultChannel(intrange.NewConstant(5)) - if err == nil { - t.Fatalf("expected error calling DefChannel without file names") - } - _, err = Load("testdata", "test.wav") - if err != nil { - t.Fatalf("expected no error loading test file") - } - ch, err := DefaultChannel(intrange.NewLinear(1, 100), "test.wav") - if err != nil { - t.Fatalf("expected no error creating channel with test file") - } - if ch == nil { - t.Fatalf("expected channel to be not-nil post create") - } - go func() { - tm := time.Now().Add(2 * time.Second) - // This only matters when running a suite of tests - for time.Now().Before(tm) { - ch <- Signal{0} - } - }() - time.Sleep(2 * time.Second) -} diff --git a/audio/channels.go b/audio/channels.go deleted file mode 100644 index b7fd64b2..00000000 --- a/audio/channels.go +++ /dev/null @@ -1,112 +0,0 @@ -package audio - -import ( - "time" - - "github.com/200sc/klangsynthese/font" - "github.com/oakmound/oak/v3/alg/range/intrange" - "github.com/oakmound/oak/v3/timing" -) - -// DefaultActiveChannel acts like GetActiveChannel when fed DefaultFont -func DefaultActiveChannel(freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return GetActiveChannel(DefaultFont, freq, fileNames...) -} - -// GetActiveChannel returns a channel that will block until its frequency -// rotates around. This means that continually sending on ChannelSignal will -// probably cause the game to freeze or substantially slow down. For this reason -// ActiveWavChannels are meant to be used for cases where the user knows they will -// not be sending on the ActiveWavChannel more often than the frequency they send -// in. -// Audio channels serve one purpose: handling audio effects -// which come in at very high or unpredictable frequencies -// while limiting the number of concurrent ongoing audio effects -// from any one source. All channels will only play once per a given -// frequency range. -func GetActiveChannel(f *font.Font, freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return getActiveChannel(f, freq, timing.ClearDelayCh, fileNames...) -} - -func getActiveChannel(f *font.Font, freq intrange.Range, quitCh chan struct{}, - fileNames ...string) (chan ChannelSignal, error) { - - datas, err := GetSounds(fileNames...) - if err != nil { - return nil, err - } - - sounds := make([]*Audio, len(datas)) - for i, d := range datas { - sounds[i] = New(f, d) - } - - soundCh := make(chan ChannelSignal) - go func() { - // Todo: When a scene ends, we need to clear all of these goroutines out - for { - delay := time.Duration(freq.Poll()) - select { - case <-quitCh: - return - case <-time.After(delay * time.Millisecond): - } - // Every once in a while, after some delay, - // we play an audio that slipped through the - // above routine. - select { - case <-quitCh: - return - case signal := <-soundCh: - sound := sounds[signal.GetIndex()] - usePos, x, y := signal.GetPos() - if usePos { - sound.X = &x - sound.Y = &y - } - sound.Play() - } - } - }() - return soundCh, nil -} - -// DefaultChannel acts like GetChannel when given DefaultFont -func DefaultChannel(freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return getChannel(DefaultFont, freq, timing.ClearDelayCh, fileNames...) -} - -// GetChannel channels will attempt to steal most sends sent to the output -// audio channel. This will allow a game to constantly send on a channel and -// obtain an output rate of near the sent in frequency instead of locking -// or requiring buffered channel usage. -// -// An important example case-- walking around -// When a character walks, they have some frequency step speed and some -// set of potential fileName sounds that play, and the usage of a channel -// here will let the EnterFrame code which detects the walking status to -// send on the walking audio channel constantly without worrying about -// triggering too many sounds. -func GetChannel(f *font.Font, freq intrange.Range, fileNames ...string) (chan ChannelSignal, error) { - return getChannel(f, freq, timing.ClearDelayCh, fileNames...) -} - -func getChannel(f *font.Font, freq intrange.Range, quitCh chan struct{}, fileNames ...string) (chan ChannelSignal, error) { - soundCh, err := getActiveChannel(f, freq, quitCh, fileNames...) - if err != nil { - return nil, err - } - - // This routine serves to steal almost every - // attempt to play audio - go func() { - for { - select { - case <-quitCh: - return - case <-soundCh: - } - } - }() - return soundCh, nil -} diff --git a/audio/ears.go b/audio/ears.go index f1ca22b3..9ab30ac9 100644 --- a/audio/ears.go +++ b/audio/ears.go @@ -1,7 +1,6 @@ package audio import ( - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/physics" ) @@ -58,15 +57,11 @@ func (e *Ears) CalculateVolume(v physics.Vector) float64 { v2 := physics.NewVector(*e.X, *e.Y) dist := v2.Distance(v) - dlog.Verb("Vector Distance:", dist, v, v2) - // Ignore scaling variable lin := (e.SilenceRadius - dist) / e.SilenceRadius if lin < 0 { lin = 0 } - dlog.Verb("Silence scale", lin, e.SilenceRadius, dist) - return lin } diff --git a/audio/flac/flac.go b/audio/flac/flac.go new file mode 100644 index 00000000..af76dcb7 --- /dev/null +++ b/audio/flac/flac.go @@ -0,0 +1,46 @@ +// Package flac provides functionality to handle .flac files and .flac encoded data +package flac + +import ( + "fmt" + "io" + + "github.com/eaburns/flac" + audio "github.com/oakmound/oak/v3/audio/klang" +) + +// def flac format +var format = audio.Format{ + SampleRate: 44100, + Bits: 16, + Channels: 2, +} + +// Load loads flac data from the incoming reader as an audio +func Load(r io.Reader) (audio.Audio, error) { + data, meta, err := flac.Decode(r) + if err != nil { + return nil, fmt.Errorf("failed to load flac: %w", err) + } + + fformat := audio.Format{ + SampleRate: uint32(meta.SampleRate), + Channels: uint16(meta.NChannels), + Bits: uint16(meta.BitsPerSample), + } + return audio.EncodeBytes( + audio.Encoding{ + Data: data, + Format: fformat, + }) +} + +// Save will eventually save an audio encoded as flac to the given writer +func Save(r io.ReadWriter, a audio.Audio) error { + return fmt.Errorf("unsupported Functionality") +} + +// Format returns the default flac formatting +func Format() audio.Format { + return format +} diff --git a/audio/flac/testdata/test.flac b/audio/flac/testdata/test.flac new file mode 100644 index 00000000..f137fb57 Binary files /dev/null and b/audio/flac/testdata/test.flac differ diff --git a/audio/flac/testdata/test2.flac b/audio/flac/testdata/test2.flac new file mode 100644 index 00000000..55d63978 Binary files /dev/null and b/audio/flac/testdata/test2.flac differ diff --git a/audio/font/audio.go b/audio/font/audio.go new file mode 100644 index 00000000..1a081566 --- /dev/null +++ b/audio/font/audio.go @@ -0,0 +1,58 @@ +package font + +import audio "github.com/oakmound/oak/v3/audio/klang" + +// Audio is an ease-of-use wrapper around an audio +// with an attached font, so that the audio can be played +// with .Play() but can take in the remotely variable +// font filter options. +// +// Note that it is a conscious choice for both Font and +// Audio to have a Filter(...Filter) function, so that when +// a FontAudio is in use the user needs to specify which +// element they want to apply a filter on. The alternative would +// be to have two similarly named functions, and its believed +// that fa.Font.Filter(...) and fa.Audio.Filter(...) is +// more or less equivalent to whatever those names would be. +type Audio struct { + *Font + audio.FullAudio + toStop audio.Audio +} + +// NewAudio returns a *FontAudio. +// For preparation against API changes, using NewAudio over Audio{} +// is recommended. +func NewAudio(f *Font, a audio.FullAudio) *Audio { + return &Audio{f, a, nil} +} + +// Play is equivalent to Audio.Font.Play(a.Audio) +func (ad *Audio) Play() <-chan error { + a2, err := ad.FullAudio.Copy() + if err != nil { + ch := make(chan error) + go func() { + ch <- err + }() + return ch + } + _, err = a2.Filter(ad.Font.Filters...) + if err != nil { + ch := make(chan error) + go func() { + ch <- err + }() + return ch + } + ad.toStop = a2 + return a2.Play() +} + +// Stop stops a font.Audio's playback +func (ad *Audio) Stop() error { + if ad.toStop != nil { + return ad.toStop.Stop() + } + return nil +} diff --git a/audio/font/ceol/ceol.go b/audio/font/ceol/ceol.go new file mode 100644 index 00000000..1e06f651 --- /dev/null +++ b/audio/font/ceol/ceol.go @@ -0,0 +1,202 @@ +// Package ceol provides functionality to handle .ceol files and .ceol encoded data (Bosca Ceoil files) +package ceol + +import ( + "io" + "io/ioutil" + "strconv" + "strings" + "time" + + "github.com/oakmound/oak/v3/audio/sequence" + "github.com/oakmound/oak/v3/audio/synth" +) + +// Raw Ceol types, holds all information in ceol file + +// Ceol represents a complete .ceol file +type Ceol struct { + Version int + Swing int + Effect int + EffectValue int + Bpm int + PatternLength int + BarLength int + Instruments []Instrument + Patterns []Pattern + LoopStart int + LoopEnd int + Arrangement [][8]int +} + +// Instrument represents a single entry in a .ceol's instrument block +type Instrument struct { + Index int + IsDrumkit int + Palette int + LPFCutoff int + LPFResonance int + Volume int +} + +// Pattern represents a single entry in a .ceol's pattern block +type Pattern struct { + Key int + Scale int + Instrument int + Palette int + Notes []Note + Filters []Filter +} + +// Note represents a single entry in a .ceol's pattern's note block +type Note struct { + PitchIndex int // C4 = 60 + Length int + Offset int +} + +// Filter represents a single entry in a .ceol's pattern's filter block +type Filter struct { + Volume int + LPFCutoff int + LPFResonance int +} + +// ChordPattern converts a Ceol's patterns and arrangement into a playable chord +// pattern for sequences +func (c Ceol) ChordPattern() sequence.ChordPattern { + chp := sequence.ChordPattern{} + chp.Pitches = make([][]synth.Pitch, c.PatternLength*len(c.Arrangement)) + chp.Holds = make([][]time.Duration, c.PatternLength*len(c.Arrangement)) + for i, m := range c.Arrangement { + for _, p := range m { + if p != -1 { + for _, n := range c.Patterns[p].Notes { + chp.Pitches[n.Offset+i*c.PatternLength] = + append(chp.Pitches[n.Offset+i*c.PatternLength], synth.NoteFromIndex(n.PitchIndex)) + chp.Holds[n.Offset+i*c.PatternLength] = + append(chp.Holds[n.Offset+i*c.PatternLength], DurationFromQuarters(c.Bpm, n.Length)) + } + } + } + } + return chp +} + +// DurationFromQuarters should not be here, should be in a package +// managing bpm and time +// Duration from quarters expects four quarters to occur per beat, +// (direct complaints at terry cavanagh), and returns a time.Duration +// for n quarters in the given bpm. +func DurationFromQuarters(bpm, quarters int) time.Duration { + beatTime := time.Duration(60000/bpm) * time.Millisecond + quarterTime := beatTime / 4 + return quarterTime * time.Duration(quarters) +} + +// Open returns a Ceol from an io.Reader +func Open(r io.Reader) (Ceol, error) { + c := Ceol{} + b, err := ioutil.ReadAll(r) + if err != nil { + return c, err + } + s := string(b) + in := strings.Split(s, ",") + ints := make([]int, len(in)) + for i := 0; i < len(in)-1; i++ { + ints[i], err = strconv.Atoi(in[i]) + if err != nil { + return c, err + } + } + i := 0 + c.Version = ints[i] + i++ + c.Swing = ints[i] + i++ + c.Effect = ints[i] + i++ + c.EffectValue = ints[i] + i++ + c.Bpm = ints[i] + i++ + c.PatternLength = ints[i] + i++ + c.BarLength = ints[i] + i++ + nInstruments := ints[i] + i++ + c.Instruments = make([]Instrument, nInstruments) + for j := 0; j < nInstruments; j++ { + c.Instruments[j].Index = ints[i] + i++ + c.Instruments[j].IsDrumkit = ints[i] + i++ + c.Instruments[j].Palette = ints[i] + i++ + c.Instruments[j].LPFCutoff = ints[i] + i++ + c.Instruments[j].LPFResonance = ints[i] + i++ + c.Instruments[j].Volume = ints[i] + i++ + } + nPatterns := ints[i] + i++ + c.Patterns = make([]Pattern, nPatterns) + for j := 0; j < nPatterns; j++ { + c.Patterns[j].Key = ints[i] + i++ + c.Patterns[j].Scale = ints[i] + i++ + c.Patterns[j].Instrument = ints[i] + i++ + c.Patterns[j].Palette = ints[i] + i++ + nNotes := ints[i] + i++ + c.Patterns[j].Notes = make([]Note, nNotes) + for k := 0; k < nNotes; k++ { + c.Patterns[j].Notes[k].PitchIndex = ints[i] + i++ + c.Patterns[j].Notes[k].Length = ints[i] + i++ + c.Patterns[j].Notes[k].Offset = ints[i] + i++ + i++ // Dummy value here + } + hasFilter := ints[i] + i++ + var nFilters int + if hasFilter == 1 { + nFilters = ints[i] + i++ + } + c.Patterns[j].Filters = make([]Filter, nFilters) + for k := 0; k < nFilters; k++ { + c.Patterns[j].Filters[k].Volume = ints[i] + i++ + c.Patterns[j].Filters[k].LPFCutoff = ints[i] + i++ + c.Patterns[j].Filters[k].LPFResonance = ints[i] + i++ + } + } + songLength := ints[i] + i++ + c.LoopStart = ints[i] + i++ + c.LoopEnd = ints[i] + i++ + c.Arrangement = make([][8]int, songLength) + for j := 0; j < songLength; j++ { + for k := 0; k < 8; k++ { + c.Arrangement[j][k] = ints[i] + i++ + } + } + return c, nil +} diff --git a/audio/font/ceol/testdata/test.ceol b/audio/font/ceol/testdata/test.ceol new file mode 100644 index 00000000..09c24d30 --- /dev/null +++ b/audio/font/ceol/testdata/test.ceol @@ -0,0 +1 @@ +3,0,0,0,150,32,4,2,134,0,1,128,0,256,141,0,20,128,0,256,3,10,2,0,1,26,77,1,0,0,73,1,1,0,70,1,2,0,63,1,4,0,60,1,5,0,84,1,0,0,81,1,1,0,77,1,2,0,73,1,3,0,70,1,4,0,67,1,5,0,63,1,6,0,60,1,7,0,57,1,6,0,53,1,7,0,67,1,3,0,82,1,0,0,77,1,1,0,72,1,2,0,70,1,3,0,69,1,4,0,63,1,5,0,58,1,6,0,57,1,7,0,34,32,16,0,41,32,16,0,0,0,0,1,20,10,70,1,0,0,69,1,0,0,66,1,2,0,64,1,2,0,59,1,4,0,58,1,4,0,57,1,4,0,54,1,6,0,49,1,9,0,48,1,9,0,0,0,0,0,1,0,0,2,0,2,0,-1,-1,-1,-1,-1,-1,-1,2,-1,-1,-1,-1,-1,-1,-1, \ No newline at end of file diff --git a/audio/font/dls/dls.go b/audio/font/dls/dls.go new file mode 100644 index 00000000..4a4d5862 --- /dev/null +++ b/audio/font/dls/dls.go @@ -0,0 +1,155 @@ +// Package dls contains data structures for DLS (.dls) file types +package dls + +import "github.com/oakmound/oak/v3/audio/font/riff" + +// The DLS is the major struct we care about in this package +// DLS files contain instrument and wave sample information, and +// a bunch of other things users probably don't care about. +type DLS struct { + Dlid ID `riff:"dlid"` + Colh uint32 `riff:"colh"` + Vers int64 `riff:"vers"` + Lins []Ins `riff:"lins"` + Ptbl []byte `riff:"ptbl"` //PoolTable + Wvpl []Wave `riff:"wvpl"` + riff.INFO `riff:"INFO"` +} + +// PoolTable is a goofy name for a thing that redirects references +// between instruments and waves, I think. +type PoolTable struct { + CbSize uint32 + CCues uint32 + // CCues size + PoolCues []uint32 +} + +// An ID is a unique identifer for a dls file or instrument (or wave). +// This could just be written as a complex128. +type ID struct { + UlData1 uint32 + UlData2 uint16 + UlData3 uint16 + AbData4 [8]byte +} + +// Wave is the underlying struct you'd also find in WAV files. It stores raw +// audio information and headers describing how to play that information. The +// DLS Wave struct can also have a DLSID. Todo: Consider moving this out of this +// file entirely and into the WAV package, the downside of which would be that +// if a user wanted access to a DLSID it would no longer be there to get. +type Wave struct { + Dlid ID `riff:"dlid"` + Guid []byte `riff:"guid"` + Wavu []byte `riff:"wavu"` + Fmt PCMFormat `riff:"fmt "` + Wavh []byte `riff:"wavh"` + Smpl []byte `riff:"smpl"` + Wsmp []byte `riff:"wsmp"` + // Data is the stuff you actually care about + Data []byte `riff:"data"` + riff.INFO `riff:"INFO"` +} + +// PCMFormat is a wave format that just know how many bits per sample a wave +// takes, beyond common format fields +// Really, there are two formats a Wave can take, this is just the one we hope +// to see. Todo: Fix that +type PCMFormat struct { + AudioFormat uint16 + NumChannels uint16 + SampleRate uint32 + ByteRate uint32 + BlockAlign uint16 + BitsPerSample uint16 + WhoKnows uint16 // Test files show there are buffer bytes here? +} + +// An Ins holds instrument data +type Ins struct { + Dlid ID `riff:"dlid"` + Insh InsHeader `riff:"insh"` + Lrgn []Rgn `riff:"lrgn"` + Lart Art `riff:"lart"` + riff.INFO `riff:"INFO"` +} + +// InsHeader stores header information for an instrument, notably the number +// of regions and the internal instrument bank and number +type InsHeader struct { + CRegions uint32 + Locale MIDILOCALE +} + +// MIDILOCALE stores two of the fields in an instrument header +type MIDILOCALE struct { + UlBank uint32 + UlInstrument uint32 +} + +// An Art is something we need to look more into +type Art struct { + // Todo: art1 doesn't fit our unmarshaler's expectations, because + // it's basically its own type of subchunk with two sizes following + // 'art1' then a number of structs based on the second size + Art1 []byte `riff:"art1"` + // This []byte is really: + // cbSize uint32 + // cConnectionBlocks uint32 + // ConnectionBlocks []ConnectionBlock + // Also Art2, which is equivalent to Art1 +} + +// Rgn is a region linking instruments to waves +type Rgn struct { + Rgnh RgnHeader `riff:"rgnh"` + Wsmp []byte `riff:"wsmp"` + Wlnk WaveLink `riff:"wlnk"` + Lart Art `riff:"lart"` +} + +// An RgnHeader stores header information for regions, notably for one the valid range +// of notes an instrument should be applied to +// Todo: figure out how to distinguish between rgnhs with and without ulLayer +type RgnHeader struct { + RangeKey RGNRANGE + RangeVelocity RGNRANGE + FusOptions uint16 + UsKeyGroup uint16 + UsLayer uint16 // This field is optional +} + +// An RGNRANGE just stores a low and a high value +type RGNRANGE struct { + UsLow uint16 + UsHigh uint16 +} + +// WaveLink stores things I don't know about +type WaveLink struct { + FusOptions uint16 + UsPhaseGroup uint16 + UlChannel uint32 + UlTableIndex uint32 +} + +// WaveSample also stores things I don't know about +type WaveSample struct { + CbSize uint32 + UsUnityNote uint16 + SFineTune int16 + LGain int32 + FulOptions uint32 + // As for art, WaveSampleLoop is CSampleLoops long + CSampleLoops uint32 + WaveSampleLoop []WaveSampleLoop +} + +// WaveSampleLoop also stores things I don't know about +type WaveSampleLoop struct { + CbSize uint32 + UlLoopType uint32 + UlLoopStart uint32 + UlLoopLength uint32 +} diff --git a/audio/font/dls/testdata/SanbikiSCC.dls b/audio/font/dls/testdata/SanbikiSCC.dls new file mode 100644 index 00000000..c73ad066 Binary files /dev/null and b/audio/font/dls/testdata/SanbikiSCC.dls differ diff --git a/audio/font/font.go b/audio/font/font.go new file mode 100644 index 00000000..aea91026 --- /dev/null +++ b/audio/font/font.go @@ -0,0 +1,45 @@ +// Package font provides utilities to package together audio manipulations as +// a 'font' +package font + +import audio "github.com/oakmound/oak/v3/audio/klang" + +// Font represents some group of settings which modify how an Audio +// should be played. The name is derived from the concept of a SoundFont +type Font struct { + Filters []audio.Filter +} + +// New returns a *Font. +// It is recommended for future API changes to avoid &Font{} and use NewFont instead +func New() *Font { + return &Font{} +} + +// Filter on a font is applied to all audios as they are played. +// Each call of Filter will completely reset a Font's filters +func (f *Font) Filter(fs ...audio.Filter) *Font { + f.Filters = fs + return f +} + +// Play on a font is equivalent to Audio.Copy().Filter(Font.GetFilters()).Play() +func (f *Font) Play(a audio.Audio) <-chan error { + a2, err := a.Copy() + if err != nil { + ch := make(chan error) + go func() { + ch <- err + }() + return ch + } + _, err = a2.Filter(f.Filters...) + if err != nil { + ch := make(chan error) + go func() { + ch <- err + }() + return ch + } + return a2.Play() +} diff --git a/audio/font/riff/info.go b/audio/font/riff/info.go new file mode 100644 index 00000000..bdb2a05d --- /dev/null +++ b/audio/font/riff/info.go @@ -0,0 +1,41 @@ +package riff + +// INFO is a common RIFF component. Most of these fields will be absent on +// any given INFO struct. Todo: consider if these should be given names +// that are informative instead of representative of their structural tag +type INFO struct { + // Arhcival Location + IARL string `riff:"IARL"` + // Arist + IART string `riff:"IART"` + // Commissioned By + ICMS string `riff:"ICMS"` + // Comments + ICMT string `riff:"ICMT"` + // Copyright + ICOP string `riff:"ICOP"` + // Creation Date + ICRD string `riff:"ICRD"` + // Engineer + IENG string `riff:"IENG"` + // Genre + IGNR string `riff:"IGNR"` + // Keywords + IKEY string `riff:"IKEY"` + // Medium + IMED string `riff:"IMED"` + // Name + INAM string `riff:"INAM"` + // Product + IPRD string `riff:"IPRD"` + // Subject + ISBJ string `riff:"ISBJ"` + // Software + ISFT string `riff:"ISFT"` + // Source + ISRC string `riff:"ISRC"` + // Source Form + ISRF string `riff:"ISRF"` + // Technician + ITCH string `riff:"ITCH"` +} diff --git a/audio/font/riff/riff.go b/audio/font/riff/riff.go new file mode 100644 index 00000000..ae5ed154 --- /dev/null +++ b/audio/font/riff/riff.go @@ -0,0 +1,366 @@ +// Package riff reads and umarshalls RIFF files +package riff + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "reflect" + "strconv" +) + +// A Reader is a bytes reader with some helper functions to read IDs, Lens, and Data +// from RIFF files. +type Reader struct { + *bytes.Reader +} + +// NewReader returns an initial Reader +func NewReader(data []byte) *Reader { + return &Reader{ + Reader: bytes.NewReader(data), + } +} + +// Print prints a reader without any knowledge of the structure of the reader, +// so all values will be []bytes. +// It assumes the reader has not advanced at all. Todo: Change that +func (r *Reader) Print() { + deepPrint(r, " ", -1) +} + +func deepPrint(r *Reader, prefix string, readLimit int) { + var err error + var typ string + var l uint32 + var data []byte + var isList bool + var read int + for err == nil && readLimit == -1 || read < readLimit { + typ, l, isList, err = r.NextIDLen() + // There will be a bogus byte at the end of some prints. + if l%2 != 0 { + l++ + } + read += 8 + if err == nil { + fmt.Print(prefix, typ, " Length:", l) + if isList { + typ2, err2 := r.NextID() + read += 4 + if err2 == nil { + fmt.Println(prefix+" ", typ2) + deepPrint(r, prefix+" ", int(l)-4) + } else { + fmt.Println(prefix, err2) + } + } else if l < 40 { + data = make([]byte, l) + r.Read(data) + fmt.Println(" Content:", data) + } else { + r.Seek(int64(l), io.SeekCurrent) + fmt.Println(" Long Content") + } + read += int(l) + } + } + if err != nil && err != io.EOF { + fmt.Println(prefix, err) + } +} + +// Unmarshal is a mirror of json.Unmarshal, for RIFF files +func Unmarshal(data []byte, v interface{}) error { + return NewReader(data).unmarshal(v) +} + +func (r *Reader) unmarshal(v interface{}) error { + // Mirrors json.unmarshal + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return errors.New("Invalid Unmarshal Struct") + } + // The first ID in the riff should be RIFF + id, err := r.NextID() + if err != nil { + return err + } + if id != "RIFF" { + return errors.New("RIFF format must begin with RIFF") + } + ln, err := r.NextLen() + if err != nil { + return err + } + // The next ID identifies this file type. We don't want it. + _, err = r.NextID() + if err != nil { + return err + } + _, err = r.chunks(reflect.Indirect(rv), int(ln)) + return err +} + +// NextID returns the next four byte sof the reader as a string +func (r *Reader) NextID() (string, error) { + id := make([]byte, 4) + l, err := r.Reader.Read(id) + if l != 4 || err != nil { + return "", errors.New("RIFF missing expected ID") + } + return string(id), nil +} + +// NextIDLen returns NextID and NextLen +func (r *Reader) NextIDLen() (string, uint32, bool, error) { + id, err := r.NextID() + if err != nil { + return "", 0, false, err + } + ln, err := r.NextLen() + if err != nil { + return "", 0, false, err + } + return id, ln, id == "LIST" || id == "RIFF", nil +} + +// NextLen returns the next four bytes of the reader as a length. +func (r *Reader) NextLen() (uint32, error) { + var ln uint32 + err := binary.Read(r.Reader, binary.LittleEndian, &ln) + if err != nil { + return ln, errors.New("RIFF missing expected length") + } + return ln, nil +} + +func (r *Reader) chunks(rv reflect.Value, inLength int) (reflect.Value, error) { + // Find chunkId in rv + // If it can't be found, ignore it as a value the user does not want + switch rv.Kind() { + case reflect.Struct: + return rv, r.structChunks(rv, inLength) + case reflect.Slice: + return r.sliceChunks(rv, inLength) + default: + return reflect.Value{}, errors.New("Unsupported unmarshal type") + } +} + +func (r *Reader) sliceChunks(rv reflect.Value, inLength int) (reflect.Value, error) { + + slTy := rv.Type() + ty := slTy.Elem() + newSlice := reflect.MakeSlice(slTy, 0, 10000) + for inLength > 0 { + _, ln, isList, err := r.NextIDLen() + if err != nil { + return reflect.Value{}, err + } + if !isList { + return reflect.Value{}, errors.New("Slice structs need to be LISTs") + } + ln -= 4 + inLength -= 4 + if inLength <= 0 { + break + } + _, err = r.NextID() + if err != nil { + return reflect.Value{}, err + } + + inLength -= 8 + if inLength <= 0 { + break + } + newStruct := reflect.New(ty) + err = r.structChunks(reflect.Indirect(newStruct), int(ln)) + if err != nil { + return reflect.Value{}, err + } + newSlice = reflect.Append(newSlice, reflect.Indirect(newStruct)) + if ln%2 != 0 { + r.Reader.ReadByte() + inLength-- + } + inLength -= int(ln) + } + return newSlice, nil +} + +// structChunks reads chunks and matches them to fields on rv (which is a struct) +// structChunks sets the fields of rv to be the output it gets +func (r *Reader) structChunks(rv reflect.Value, inLength int) error { + chunkID, ln, isList, err := r.NextIDLen() + if err != nil { + return err + } + if isList { + ln -= 4 + inLength -= 4 + chunkID, err = r.NextID() + if err != nil { + return err + } + } + inLength -= 8 + ty := reflect.TypeOf(rv.Interface()) + fields := make([]reflect.Value, rv.NumField()) + fieldTags := make([]reflect.StructTag, rv.NumField()) + for i := range fields { + fields[i] = rv.Field(i) + fieldTags[i] = ty.Field(i).Tag + } + i := 0 + for inLength > 0 { + tag := fieldTags[i].Get("riff") + //spew.Dump(fields[i]) + if tag == chunkID { + // get contents from recursive call + var content reflect.Value + if isList { + content, err = r.chunks(fields[i], int(ln)) + } else { + content, err = r.fieldValue(fields[i], ln) + } + if err != nil { + return err + } + inLength -= int(ln) + + fields[i].Set(content) + // if length is odd read one more + if ln%2 != 0 { + r.Reader.ReadByte() + inLength-- + } + if inLength <= 0 { + return nil + } + // next id + chunkID, ln, isList, err = r.NextIDLen() + if err != nil { + return err + } + if isList { + ln -= 4 + inLength -= 4 + chunkID, err = r.NextID() + if err != nil { + return err + } + } + inLength -= 8 + i = -1 + } + if inLength <= 0 { + return nil + } + i++ + if i >= len(fields) { + // Skip this id + // if length is odd read one more + if ln%2 != 0 { + ln++ + } + _, err = r.Reader.Seek(int64(ln), io.SeekCurrent) + if err != nil { + return err + } + inLength -= int(ln) + if inLength <= 0 { + return nil + } + // next id + chunkID, ln, isList, err = r.NextIDLen() + if err != nil { + return err + } + if isList { + ln -= 4 + inLength -= 4 + chunkID, err = r.NextID() + if err != nil { + return err + } + } + inLength -= 8 + i = 0 + } + } + return nil +} + +// Todo: the switch here should change to some separate functions, there's some +// repetition here that is not necessary. +func (r *Reader) fieldValue(rv reflect.Value, ln uint32) (reflect.Value, error) { + switch rv.Kind() { + case reflect.Struct: + st := rv.Addr().Interface() + err := binary.Read(r.Reader, binary.LittleEndian, st) + if err != nil { + // Something on this struct has an undefined size + // Read each field in part by part. + } + return reflect.Indirect(reflect.ValueOf(st)), err + case reflect.String: + data := make([]byte, ln) + n, err := r.Reader.Read(data) + if n != int(ln) { + return reflect.Value{}, errors.New("Insufficient data found in RIFF data block") + } + return reflect.ValueOf(string(data)), err + case reflect.Slice: + switch rv.Type().Elem().Kind() { + case reflect.Uint8: + data := make([]byte, ln) + n, err := r.Reader.Read(data) + if n != int(ln) { + return reflect.Value{}, errors.New("Insufficient data found in RIFF data block") + } + return reflect.ValueOf(data), err + default: + return reflect.Value{}, errors.New("Unsupported type in input struct") + } + case reflect.Uint32: + if ln != 4 { + return reflect.Value{}, errors.New("Invalid length for uint32: " + strconv.Itoa(int(ln))) + } + data := make([]byte, ln) + n, err := r.Reader.Read(data) + if n != int(ln) { + return reflect.Value{}, errors.New("Insufficient data found in RIFF data block") + } + if err != nil { + return reflect.Value{}, err + } + val, n := binary.Uvarint(data) + if n <= 0 { + return reflect.Value{}, errors.New("Unable to decode int64 from data") + } + val32 := uint32(val) + return reflect.ValueOf(val32), nil + case reflect.Int64: + if ln != 8 { + return reflect.Value{}, errors.New("Invalid length for int64: " + strconv.Itoa(int(ln))) + } + data := make([]byte, ln) + n, err := r.Reader.Read(data) + if n != int(ln) { + return reflect.Value{}, errors.New("Insufficient data found in RIFF data block") + } + if err != nil { + return reflect.Value{}, err + } + val, n := binary.Varint(data) + if n <= 0 { + return reflect.Value{}, errors.New("Unable to decode int64 from data") + } + return reflect.ValueOf(val), nil + } + return reflect.Value{}, nil +} diff --git a/audio/fontManager.go b/audio/fontManager.go index 503dbecf..164b7b26 100644 --- a/audio/fontManager.go +++ b/audio/fontManager.go @@ -1,7 +1,7 @@ package audio import ( - "github.com/200sc/klangsynthese/font" + "github.com/oakmound/oak/v3/audio/font" "github.com/oakmound/oak/v3/oakerr" ) diff --git a/audio/fontManager_test.go b/audio/fontManager_test.go index afa9c92f..950095a6 100644 --- a/audio/fontManager_test.go +++ b/audio/fontManager_test.go @@ -3,7 +3,7 @@ package audio import ( "testing" - "github.com/200sc/klangsynthese/font" + "github.com/oakmound/oak/v3/audio/font" ) func TestFontManager(t *testing.T) { diff --git a/audio/globals.go b/audio/globals.go index 21149665..802b116b 100644 --- a/audio/globals.go +++ b/audio/globals.go @@ -3,7 +3,7 @@ package audio import ( "sync" - "github.com/200sc/klangsynthese/font" + "github.com/oakmound/oak/v3/audio/font" ) var ( diff --git a/audio/klang/audio.go b/audio/klang/audio.go new file mode 100644 index 00000000..5b8b2b62 --- /dev/null +++ b/audio/klang/audio.go @@ -0,0 +1,53 @@ +// Package audio provides audio playing and encoding support +package klang + +import ( + "time" + + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// Audio represents playable, filterable audio data. +type Audio interface { + // Play returns a channel that will signal when it finishes playing. + // Looping audio will never send on this channel! + // The value sent will always be true. + Play() <-chan error + // Filter will return an audio with some desired filters applied + Filter(...Filter) (Audio, error) + MustFilter(...Filter) Audio + // Stop will stop an ongoing audio + Stop() error + + // Implementing struct-- encoding + Copy() (Audio, error) + MustCopy() Audio + PlayLength() time.Duration + + // SetVolume sets the volume of an audio at an OS level, + // post filters. It multiplies with any volume filters. + // It takes a value from 0 to -10000, and can only reduce + // volume from the raw input. + SetVolume(int32) error +} + +// FullAudio supports all the built in filters +type FullAudio interface { + Audio + supports.Encoding + supports.Loop +} + +// Stream represents an audio stream. unlike Audio, the length of the +// stream is unknown. Copy is also not supported. +type Stream interface { + // Play returns a channel that will signal when it finishes playing. + // Looping audio will never send on this channel! + // The value sent will always be true. + Play() <-chan error + // Filter will return an audio with some desired filters applied + Filter(...Filter) (Audio, error) + MustFilter(...Filter) Audio + // Stop will stop an ongoing audio + Stop() error +} diff --git a/audio/klang/audio_windows.go b/audio/klang/audio_windows.go new file mode 100644 index 00000000..83ba56da --- /dev/null +++ b/audio/klang/audio_windows.go @@ -0,0 +1,95 @@ +//+build windows + +package klang + +import ( + "errors" + + "github.com/oov/directsound-go/dsound" +) + +type dsAudio struct { + *Encoding + *dsound.IDirectSoundBuffer + flags dsound.BufferPlayFlag +} + +func (ds *dsAudio) Play() <-chan error { + ch := make(chan error) + if ds.Loop { + ds.flags = dsound.DSBPLAY_LOOPING + } + go func(dsbuff *dsound.IDirectSoundBuffer, flags dsound.BufferPlayFlag, ch chan error) { + err := dsbuff.SetCurrentPosition(0) + if err != nil { + select { + case ch <- err: + default: + } + } else { + err = dsbuff.Play(0, flags) + if err != nil { + select { + case ch <- err: + default: + } + } else { + select { + case ch <- nil: + default: + } + } + } + }(ds.IDirectSoundBuffer, ds.flags, ch) + return ch +} + +func (ds *dsAudio) Stop() error { + err := ds.IDirectSoundBuffer.Stop() + if err != nil { + return err + } + return ds.IDirectSoundBuffer.SetCurrentPosition(0) +} + +// SetVolume uses an underlying directsound command to set +// the volume of the audio. Applies multiplicatively with volume +// filters. Accepts int32s from -10000 to 0, 0 being the max and +// default volume. +func (ds *dsAudio) SetVolume(vol int32) error { + return ds.IDirectSoundBuffer.SetVolume(vol) +} + +func (ds *dsAudio) Filter(fs ...Filter) (Audio, error) { + var a Audio = ds + var err, consErr error + for _, f := range fs { + a, err = f.Apply(a) + if err != nil { + if consErr == nil { + consErr = err + } else { + consErr = errors.New(err.Error() + ":" + consErr.Error()) + } + } + } + // Consider: this is a significant amount + // of work to do just to make this an in-place filter. + // would it be worth it to offer both in place and non-inplace + // filter functions? + a2, err2 := EncodeBytes(*ds.Encoding) + if err2 != nil { + return nil, err2 + } + // reassign the contents of ds to be that of the + // new audio, so that this filters in place + *ds = *a2.(*dsAudio) + return ds, consErr +} + +// MustFilter acts like Filter, but ignores errors (it does not panic, +// as filter errors are expected to be non-fatal) +func (ds *dsAudio) MustFilter(fs ...Filter) Audio { + a, _ := ds.Filter(fs...) + return a +} diff --git a/audio/klang/encode_darwin.go b/audio/klang/encode_darwin.go new file mode 100644 index 00000000..b0cc3b3c --- /dev/null +++ b/audio/klang/encode_darwin.go @@ -0,0 +1,50 @@ +//+build darwin + +package klang + +import "errors" + +type darwinNopAudio struct { + Encoding +} + +func (dna *darwinNopAudio) Play() <-chan error { + ch := make(chan error) + go func() { + ch <- errors.New("Playback on Darwin is not supported") + }() + return ch +} + +func (dna *darwinNopAudio) Stop() error { + return errors.New("Playback on Darwin is not supported") +} + +func (dna *darwinNopAudio) SetVolume(int32) error { + return errors.New("SetVolume on Darwin is not supported") +} + +func (dna *darwinNopAudio) Filter(fs ...Filter) (Audio, error) { + var a Audio = dna + var err, consErr error + for _, f := range fs { + a, err = f.Apply(a) + if err != nil { + if consErr == nil { + consErr = err + } else { + consErr = errors.New(err.Error() + ":" + consErr.Error()) + } + } + } + return dna, consErr +} + +func (dna *darwinNopAudio) MustFilter(fs ...Filter) Audio { + a, _ := dna.Filter(fs...) + return a +} + +func EncodeBytes(enc Encoding) (Audio, error) { + return &darwinNopAudio{enc}, nil +} diff --git a/audio/klang/encode_linux.go b/audio/klang/encode_linux.go new file mode 100644 index 00000000..91e13f28 --- /dev/null +++ b/audio/klang/encode_linux.go @@ -0,0 +1,216 @@ +//+build linux + +package klang + +import ( + "errors" + "strings" + "sync" + + "github.com/yobert/alsa" +) + +type alsaAudio struct { + *Encoding + *alsa.Device + playAmount int + playProgress int + stopCh chan struct{} + playing bool + playCh chan error + period int +} + +func (aa *alsaAudio) Play() <-chan error { + // If currently playing, restart + if aa.playing { + aa.playProgress = 0 + return aa.playCh + } + aa.playing = true + aa.playCh = make(chan error) + go func() { + for { + var data []byte + if len(aa.Encoding.Data)-aa.playProgress <= aa.playAmount { + data = aa.Encoding.Data[aa.playProgress:] + if aa.Loop { + delta := aa.playAmount - (len(aa.Encoding.Data) - aa.playProgress) + data = append(data, aa.Encoding.Data[:delta]...) + } + } else { + data = aa.Encoding.Data[aa.playProgress : aa.playProgress+aa.playAmount] + } + if len(data) != 0 { + err := aa.Device.Write(data, aa.period) + if err != nil { + select { + case aa.playCh <- err: + default: + } + break + } + } + aa.playProgress += aa.playAmount + if aa.playProgress > len(aa.Encoding.Data) { + if aa.Loop { + aa.playProgress %= len(aa.Encoding.Data) + } else { + select { + case aa.playCh <- nil: + default: + } + break + } + } + select { + case <-aa.stopCh: + select { + case aa.playCh <- nil: + default: + } + break + default: + } + } + aa.playing = false + aa.playProgress = 0 + }() + return aa.playCh +} + +func (aa *alsaAudio) Stop() error { + if aa.playing { + go func() { + aa.stopCh <- struct{}{} + }() + } else { + return errors.New("Audio not playing, cannot stop") + } + return nil +} + +func (aa *alsaAudio) SetVolume(int32) error { + return errors.New("SetVolume on Linux is not supported") +} + +func (aa *alsaAudio) Filter(fs ...Filter) (Audio, error) { + var a Audio = aa + var err, consErr error + for _, f := range fs { + a, err = f.Apply(a) + if err != nil { + if consErr == nil { + consErr = err + } else { + consErr = errors.New(err.Error() + ":" + consErr.Error()) + } + } + } + return aa, consErr +} + +// MustFilter acts like Filter, but ignores errors (it does not panic, +// as filter errors are expected to be non-fatal) +func (aa *alsaAudio) MustFilter(fs ...Filter) Audio { + a, _ := aa.Filter(fs...) + return a +} + +func EncodeBytes(enc Encoding) (Audio, error) { + handle, err := openDevice() + if err != nil { + return nil, err + } + // Todo: annotate these errors with more info + format, err := alsaFormat(enc.Bits) + if err != nil { + return nil, err + } + _, err = handle.NegotiateFormat(format) + if err != nil { + return nil, err + } + _, err = handle.NegotiateRate(int(enc.SampleRate)) + if err != nil { + return nil, err + } + _, err = handle.NegotiateChannels(int(enc.Channels)) + if err != nil { + return nil, err + } + // Default value at recommendation of library + period, err := handle.NegotiatePeriodSize(2048) + if err != nil { + return nil, err + } + _, err = handle.NegotiateBufferSize(4096) + if err != nil { + return nil, err + } + err = handle.Prepare() + if err != nil { + return nil, err + } + return &alsaAudio{ + playAmount: period * int(enc.Bits) / 4, + period: period, + Encoding: &enc, + Device: handle, + stopCh: make(chan struct{}), + }, nil +} + +var ( + // Todo: support more customized audio device usage + openDeviceLock sync.Mutex + openedDevice *alsa.Device +) + +func openDevice() (*alsa.Device, error) { + openDeviceLock.Lock() + defer openDeviceLock.Unlock() + + if openedDevice != nil { + return openedDevice, nil + } + cards, err := alsa.OpenCards() + if err != nil { + return nil, err + } + defer alsa.CloseCards(cards) + for i, c := range cards { + dvcs, err := c.Devices() + if err != nil { + continue + } + for _, d := range dvcs { + if d.Type != alsa.PCM || !d.Play { + continue + } + if strings.Contains(d.Title, SkipDevicesContaining) { + continue + } + d.Close() + err := d.Open() + if err != nil { + continue + } + // We've a found a device we can hypothetically use + cards = append(cards[:i], cards[i+1:]...) + openedDevice = d + return d, nil + } + } + return nil, errors.New("No valid device found") +} + +func alsaFormat(bits uint16) (alsa.FormatType, error) { + switch bits { + case 8: + return alsa.S8, nil + case 16: + return alsa.S16_LE, nil + } + return 0, errors.New("Undefined alsa format for encoding bits") +} diff --git a/audio/klang/encode_windows.go b/audio/klang/encode_windows.go new file mode 100644 index 00000000..e586aaca --- /dev/null +++ b/audio/klang/encode_windows.go @@ -0,0 +1,100 @@ +//+build windows + +package klang + +import ( + "errors" + "fmt" + "strings" + "syscall" + + "github.com/oov/directsound-go/dsound" +) + +var ( + user32 = syscall.NewLazyDLL("user32") + getDesktopWindow = user32.NewProc("GetDesktopWindow") + ds *dsound.IDirectSound + err error +) + +func init() { + hasDefaultDevice := false + dsound.DirectSoundEnumerate(func(guid *dsound.GUID, description string, module string) bool { + if guid == nil { + hasDefaultDevice = true + return false + } + return true + }) + if !hasDefaultDevice { + ds = nil + err = errors.New("No default device available to play audio off of") + return + } + + ds, err = dsound.DirectSoundCreate(nil) + if err != nil { + return + } + // We don't check this error because Call() can return + // "The operation was completed successfully" as an error! + // Todo: type switch? Do we know the type of "success errors"? + desktopWindow, _, err := getDesktopWindow.Call() + if !strings.Contains(err.Error(), "success") { + fmt.Println("Dsound initialization result:", err) + } + err = ds.SetCooperativeLevel(syscall.Handle(desktopWindow), dsound.DSSCL_PRIORITY) + if err != nil { + ds = nil + } +} + +// EncodeBytes converts an encoding to Audio +func EncodeBytes(enc Encoding) (Audio, error) { + // An error here would be an error from init() + if err != nil { + return nil, err + } + + // Create the object which stores the wav data in a playable format + blockAlign := enc.Channels * enc.Bits / 8 + dsbuff, err := ds.CreateSoundBuffer(&dsound.BufferDesc{ + // These flags cover everything we should ever want to do + Flags: dsound.DSBCAPS_GLOBALFOCUS | dsound.DSBCAPS_GETCURRENTPOSITION2 | dsound.DSBCAPS_CTRLVOLUME | dsound.DSBCAPS_CTRLPAN | dsound.DSBCAPS_CTRLFREQUENCY | dsound.DSBCAPS_LOCDEFER, + Format: &dsound.WaveFormatEx{ + FormatTag: dsound.WAVE_FORMAT_PCM, + Channels: enc.Channels, + SamplesPerSec: enc.SampleRate, + BitsPerSample: enc.Bits, + BlockAlign: blockAlign, + AvgBytesPerSec: enc.SampleRate * uint32(blockAlign), + ExtSize: 0, + }, + BufferBytes: uint32(len(enc.Data)), + }) + if err != nil { + return nil, err + } + + // Reserve some space in the sound buffer object to write to. + // The Lock function (and by extension LockBytes) actually + // reserves two spaces, but we ignore the second. + by1, by2, err := dsbuff.LockBytes(0, uint32(len(enc.Data)), 0) + if err != nil { + return nil, err + } + + // Write to the pointer we were given. + copy(by1, enc.Data) + + // Update the buffer object with the new data. + err = dsbuff.UnlockBytes(by1, by2) + if err != nil { + return nil, err + } + return &dsAudio{ + Encoding: &enc, + IDirectSoundBuffer: dsbuff, + }, nil +} diff --git a/audio/klang/encoding.go b/audio/klang/encoding.go new file mode 100644 index 00000000..e579f70c --- /dev/null +++ b/audio/klang/encoding.go @@ -0,0 +1,53 @@ +package klang + +import "time" + +// Encoding contains all information required to convert raw data +// (currently assumed PCM data but that may/will change) into playable Audio +type Encoding struct { + // Consider: non []byte data? + // Consider: should Data be a type just like Format and CanLoop? + Data []byte + Format + CanLoop +} + +// Copy returns an audio encoded from this encoding. +// Consider: Copy might be tied to HasEncoding +func (enc *Encoding) Copy() (Audio, error) { + return EncodeBytes(*enc.copy()) +} + +// MustCopy acts like Copy, but will panic if err != nil +func (enc *Encoding) MustCopy() Audio { + a, err := EncodeBytes(*enc.copy()) + if err != nil { + panic(err) + } + return a +} + +// GetData satisfies filter.SupportsData +func (enc *Encoding) GetData() *[]byte { + return &enc.Data +} + +// PlayLength returns how long this encoding will play its data for +func (enc *Encoding) PlayLength() time.Duration { + return time.Duration( + 1000000000*float64(len(enc.Data))/ + float64(enc.SampleRate)/ + float64(enc.Channels)/ + float64(enc.Bits/8)) * time.Nanosecond +} + +// copy for an encoding just copies the encoding data, +// it does not return an audio. +func (enc *Encoding) copy() *Encoding { + newEnc := new(Encoding) + newEnc.Format = enc.Format + newEnc.CanLoop = enc.CanLoop + newEnc.Data = make([]byte, len(enc.Data)) + copy(newEnc.Data, enc.Data) + return newEnc +} diff --git a/audio/klang/filter.go b/audio/klang/filter.go new file mode 100644 index 00000000..271191be --- /dev/null +++ b/audio/klang/filter.go @@ -0,0 +1,24 @@ +package klang + +// A Filter takes an input audio and returns some new Audio from them. +// This usage implies that Audios can be copied, and that Audios have +// available information to be generically modified by a Filter. The +// functions for these capabilities are yet fleshed out. It's worth +// considering whether a Filter modifies in place. The answer is +// probably yes: +// a.Filter(fs) would modify a in place +// a.Copy().Filter(fs) would return a new audio +// Specific audio implementations could not follow this, however. +type Filter interface { + Apply(Audio) (Audio, error) +} + +// CanLoop offers composable looping +type CanLoop struct { + Loop bool +} + +// GetLoop allows CanLoop to satisfy the SupportsLoop interface +func (cl *CanLoop) GetLoop() *bool { + return &cl.Loop +} diff --git a/audio/klang/filter/data.go b/audio/klang/filter/data.go new file mode 100644 index 00000000..6dfadeee --- /dev/null +++ b/audio/klang/filter/data.go @@ -0,0 +1,19 @@ +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// Data filters are functions on []byte types +type Data func(*[]byte) + +// Apply checks that the given audio supports Data, filters if it +// can, then returns +func (df Data) Apply(a klang.Audio) (klang.Audio, error) { + if sd, ok := a.(supports.Data); ok { + df(sd.GetData()) + return a, nil + } + return a, supports.NewUnsupported([]string{"Data"}) +} diff --git a/audio/klang/filter/encoding.go b/audio/klang/filter/encoding.go new file mode 100644 index 00000000..291ccd7c --- /dev/null +++ b/audio/klang/filter/encoding.go @@ -0,0 +1,62 @@ +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" + "github.com/oakmound/oak/v3/audio/klang/manip" +) + +// Encoding filters are functions on any combination of the values +// in an audio.Encoding +type Encoding func(supports.Encoding) + +// Apply checks that the given audio supports Encoding, filters if it +// can, then returns +func (enc Encoding) Apply(a klang.Audio) (klang.Audio, error) { + if senc, ok := a.(supports.Encoding); ok { + enc(senc) + return a, nil + } + return a, supports.NewUnsupported([]string{"Encoding"}) +} + +// AssertStereo does nothing to audio that has two channels, but will convert +// mono audio to two-channeled audio with the same data on both channels +func AssertStereo() Encoding { + return func(enc supports.Encoding) { + chs := enc.GetChannels() + if *chs > 1 { + // We can't really do this for non-mono audio + return + } + *chs = 2 + data := enc.GetData() + d := *data + newData := make([]byte, len(d)*2) + byteDepth := int(*enc.GetBitDepth() / 8) + for i := 0; i < len(d); i += 2 { + for j := 0; j < byteDepth; j++ { + newData[i*2+j] = d[i+j] + newData[i*2+j+byteDepth] = d[i+j] + } + } + *data = newData + } +} + +func mod(init, inc int, modFn func(float64) float64) Encoding { + return func(enc supports.Encoding) { + data := enc.GetData() + d := *data + byteDepth := int(*enc.GetBitDepth() / 8) + switch byteDepth { + case 2: + for i := byteDepth * init; i < len(d); i += byteDepth * inc { + manip.SetInt16(d, i, manip.Round(modFn(float64(manip.GetInt16(d, i))))) + } + default: + // log unsupported byte depth + } + *data = d + } +} diff --git a/audio/klang/filter/fourier.go b/audio/klang/filter/fourier.go new file mode 100644 index 00000000..b39ba70a --- /dev/null +++ b/audio/klang/filter/fourier.go @@ -0,0 +1,94 @@ +package filter + +// these fourier functions did not work for me. +// In case I can fix them, I leave them here. +// Credit Arnaud Gatouillat + +// fourier1 has a bad name +// fourier1 is a helper function that does some kind of fourier transform math +// What are nn and isign? +// func fourier1(data []float64, nn, isign int) { +// n := nn << 1 +// j := 1 +// for i := 1; i < n; i += 2 { +// if j > i { +// data[j], data[i] = data[i], data[j] +// data[j+1], data[i+1] = data[i+1], data[j+1] +// } +// m := n >> 1 +// for m >= 2 && j > m { +// j -= m +// m >>= 1 +// } +// j += m +// } +// mmax := 2 +// for n > mmax { +// stp := 2 * mmax +// theta := math.Pi * 2 / float64(isign*mmax) +// wpr, wpi := wprWpi(theta) +// wr := 1.0 +// wi := 0.0 +// for m := 1; m < mmax; m += 2 { +// for i := m; i <= n; i += stp { +// tr := wr*data[j] - wi*data[j+1] +// ti := wr*data[j+1] - wi*data[i] +// data[j] = data[i] - tr +// data[j+1] = data[i+1] - ti +// data[i] += tr +// data[i+1] += ti +// } +// wt := wr +// wr = wr*wpr - wi*wpi + wr +// wi = wi*wpr + wt*wpi + wi +// } +// mmax = stp +// } +// } + +// func RealFourierTransform(data []float64, n, isign int) { +// theta := math.Pi / float64(n) +// var c2 float64 +// if isign == 1 { +// c2 = -.5 +// fourier1(data, n, 1) +// } else { +// c2 = .5 +// theta *= -1 +// } +// wpr, wpi := wprWpi(theta) +// wr := 1.0 + wpr +// wi := wpi +// // Wow what a great name for this variable +// n2p3 := 2*n + 3 +// for i := 2; i <= n/2; i++ { +// i1 := i + i - 1 +// i2 := i1 + 1 +// i3 := n2p3 - i2 +// i4 := i3 + 1 +// h1r := .5 * (data[i1] + data[i3]) +// h1i := .5 * (data[i2] - data[i4]) +// h2r := -c2 * (data[i2] + data[i4]) +// h2i := c2 * (data[i1] - data[i3]) +// data[i1] = h1r + wr*h2r - wi*h2i +// data[i2] = h1i + wr*h2i + wi*h2r +// data[i3] = h1r - wr*h2r + wi*h2i +// data[i4] = -h1i + wr*h2i + wi*h2r +// wt := wr +// wr = wr*wpr - wi*wpi + wr +// wi = wi*wpr + wt*wpi + wi +// } +// if isign == 1 { +// data[1], data[2] = (data[1] + data[2]), (data[1] - data[2]) +// } else { +// data[1], data[2] = .5*(data[1]+data[2]), .5*(data[1]-data[2]) +// fourier1(data, n, -1) +// } +// } + +// func wprWpi(theta float64) (float64, float64) { +// w := math.Sin(0.5 * theta) +// wpr := -2 * math.Pow(w, 2) +// wpi := math.Sin(theta) +// return wpr, wpi +// } diff --git a/audio/klang/filter/guarantees.go b/audio/klang/filter/guarantees.go new file mode 100644 index 00000000..22a22dce --- /dev/null +++ b/audio/klang/filter/guarantees.go @@ -0,0 +1,16 @@ +// Package filter provides various audio filters to be applied to audios through the +// Filter() function +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// These declarations guarantee that the filters in this package satisfy the filter interface +var ( + _ klang.Filter = SampleRate(func(*uint32) {}) + _ klang.Filter = Data(func(*[]byte) {}) + _ klang.Filter = Loop(func(*bool) {}) + _ klang.Filter = Encoding(func(supports.Encoding) {}) +) diff --git a/audio/klang/filter/loop.go b/audio/klang/filter/loop.go new file mode 100644 index 00000000..9cf8d28c --- /dev/null +++ b/audio/klang/filter/loop.go @@ -0,0 +1,34 @@ +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// Loop functions modify a boolean, with the intention that that boolean +// is a loop variable +type Loop func(*bool) + +// Apply checks that the given audio supports Loop, filters if it +// can, then returns +func (lf Loop) Apply(a klang.Audio) (klang.Audio, error) { + if sl, ok := a.(supports.Loop); ok { + lf(sl.GetLoop()) + return a, nil + } + return a, supports.NewUnsupported([]string{"Loop"}) +} + +// LoopOn sets the loop to happen +func LoopOn() Loop { + return func(b *bool) { + *b = true + } +} + +// LoopOff sets the loop to not happen +func LoopOff() Loop { + return func(b *bool) { + *b = false + } +} diff --git a/audio/klang/filter/pan.go b/audio/klang/filter/pan.go new file mode 100644 index 00000000..8e6913bf --- /dev/null +++ b/audio/klang/filter/pan.go @@ -0,0 +1,76 @@ +package filter + +import "github.com/oakmound/oak/v3/audio/klang/filter/supports" + +// LeftPan filters audio to only play on the left speaker +func LeftPan() Encoding { + return func(enc supports.Encoding) { + data := enc.GetData() + // Right/Left only makes sense for 2 channel + if *enc.GetChannels() != 2 { + return + } + // Zero out one channel + swtch := int((*enc.GetBitDepth()) / 8) + d := *data + for i := 0; i < len(d); i += (2 * swtch) { + for j := 0; j < swtch; j++ { + d[i+j] = byte((int(d[i+j]) + int(d[i+j+swtch])) / 2) + d[i+j+swtch] = 0 + } + } + *data = d + } +} + +// RightPan filters audio to only play on the right speaker +func RightPan() Encoding { + return func(enc supports.Encoding) { + data := enc.GetData() + // Right/Left only makes sense for 2 channel + if *enc.GetChannels() != 2 { + return + } + // Zero out one channel + swtch := int((*enc.GetBitDepth()) / 8) + d := *data + for i := 0; i < len(d); i += (2 * swtch) { + for j := 0; j < swtch; j++ { + d[i+j+swtch] = byte((int(d[i+j]) + int(d[i+j+swtch])) / 2) + d[i+j] = 0 + } + } + *data = d + } +} + +// Pan takes -1 <= f <= 1. +// An f of -1 represents a full pan to the left, a pan of 1 represents +// a full pan to the right. +func Pan(f float64) Encoding { + // Todo: test this is accurate + if f > 0 { + return VolumeBalance(1-f, 1) + } else if f < 0 { + return VolumeBalance(1, 1-(-1*f)) + } else { + return func(enc supports.Encoding) { + data := enc.GetData() + // Right/Left only makes sense for 2 channel + if *enc.GetChannels() != 2 { + return + } + // Zero out one channel + swtch := int((*enc.GetBitDepth()) / 8) + d := *data + for i := 0; i < len(d); i += (2 * swtch) { + for j := 0; j < swtch; j++ { + v := byte((int(d[i+j]) + int(d[i+j+swtch])) / 2) + d[i+j+swtch] = v + d[i+j] = v + } + } + *data = d + } + } +} diff --git a/audio/klang/filter/pitchshift.go b/audio/klang/filter/pitchshift.go new file mode 100644 index 00000000..3866d8cc --- /dev/null +++ b/audio/klang/filter/pitchshift.go @@ -0,0 +1,297 @@ +package filter + +import ( + "math" + + "github.com/oakmound/oak/v3/audio/klang/filter/supports" + "github.com/oakmound/oak/v3/audio/klang/manip" +) + +/***************************************************************************** +* HOME URL: http://blogs.zynaptiq.com/bernsee +* KNOWN BUGS: none +* +* SYNOPSIS: Routine for doing pitch shifting while maintaining +* duration using the Short Time Fourier Transform. +* +* DESCRIPTION: The routine takes a pitchShift factor value which is between 0.5 +* (one octave down) and 2. (one octave up). A value of exactly 1 does not change +* the pitch. numSampsToProcess tells the routine how many samples in indata[0... +* numSampsToProcess-1] should be pitch shifted and moved to outdata[0 ... +* numSampsToProcess-1]. The two buffers can be identical (ie. it can process the +* data in-place). fftFrameSize defines the FFT frame size used for the +* processing. Typical values are 1024, 2048 and 4096. It may be any value <= +* MAX_FRAME_LENGTH but it MUST be a power of 2. osamp is the STFT +* oversampling factor which also determines the overlap between adjacent STFT +* frames. It should at least be 4 for moderate scaling ratios. A value of 32 is +* recommended for best quality. sampleRate takes the sample rate for the signal +* in unit Hz, ie. 44100 for 44.1 kHz audio. The data passed to the routine in +* indata[] should be in the range [-1.0, 1.0), which is also the output range +* for the data, make sure you scale the data accordingly (for 16bit signed integers +* you would have to divide (and multiply) by 32768). +* +* COPYRIGHT 1999-2015 Stephan M. Bernsee +* +* The Wide Open License (WOL) +* +* Permission to use, copy, modify, distribute and sell this software and its +* documentation for any purpose is hereby granted without fee, provided that +* the above copyright notice and this license appear in all source copies. +* THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT EXPRESS OR IMPLIED WARRANTY OF +* ANY KIND. See http://www.dspguru.com/wol.htm for more information. +* +*****************************************************************************/ +// As is standard with translations of this code to other languages, +// Go translation copyright Patrick Stephen 2017 +// To be clear, the PitchShift function + FFT is what had to be translated + +// A PitchShifter has an encoding function that will shift +// a pitch up to an octave up or down (0.5 -> octave down, 2.0 -> octave up) +// these are for lower-level use, and a similar type that takes in steps to +// shift by (and eventually pitches to set to) will follow. +type PitchShifter interface { + PitchShift(float64) Encoding +} + +// FFTShifter holds buffers and settings for performing a pitch shift on PCM audio +type FFTShifter struct { + fftFrameSize int + oversampling int + step int + latency int + stack, frame []float64 + workBuffer []float64 + magnitudes, frequencies []float64 + synthMagnitudes, synthFrequencies []float64 + lastPhase, sumPhase []float64 + outAcc []float64 + expected float64 + window, windowFactors []float64 +} + +// These are built in shifters with some common inputs +var ( + LowQualityShifter, _ = NewFFTShifter(1024, 8) + HighQualityShifter, _ = NewFFTShifter(1024, 32) +) + +// NewFFTShifter returns a pitch shifter that uses fast fourier transforms +func NewFFTShifter(fftFrameSize int, oversampling int) (PitchShifter, error) { + // Todo: check that the frame size and oversampling rate make sense + ps := FFTShifter{} + ps.fftFrameSize = fftFrameSize + ps.oversampling = oversampling + ps.step = fftFrameSize / oversampling + ps.latency = fftFrameSize - ps.step + ps.stack = make([]float64, fftFrameSize) + ps.workBuffer = make([]float64, 2*fftFrameSize) + ps.magnitudes = make([]float64, fftFrameSize) + ps.frequencies = make([]float64, fftFrameSize) + ps.synthMagnitudes = make([]float64, fftFrameSize) + ps.synthFrequencies = make([]float64, fftFrameSize) + ps.lastPhase = make([]float64, fftFrameSize/2+1) + ps.sumPhase = make([]float64, fftFrameSize/2+1) + ps.outAcc = make([]float64, 2*fftFrameSize) + + ps.expected = 2 * math.Pi * float64(ps.step) / float64(fftFrameSize) + + ps.window = make([]float64, fftFrameSize) + ps.windowFactors = make([]float64, fftFrameSize) + t := 0.0 + for i := 0; i < fftFrameSize; i++ { + w := -0.5*math.Cos(t) + .5 + ps.window[i] = w + ps.windowFactors[i] = w * (2.0 / float64(fftFrameSize*oversampling)) + t += (math.Pi * 2) / float64(fftFrameSize) + } + + ps.frame = make([]float64, fftFrameSize) + return ps, nil +} + +// PitchShift modifies filtered audio by the input float, between 0.5 and 2.0, +// each end of the spectrum representing octave down and up respectively +func (ps FFTShifter) PitchShift(shiftBy float64) Encoding { + return func(senc supports.Encoding) { + data := *senc.GetData() + bitDepth := *senc.GetBitDepth() + byteDepth := bitDepth / 8 + sampleRate := *senc.GetSampleRate() + channels := *senc.GetChannels() + + // Jeeez + out := make([]byte, len(data)) + copy(out, data) + + freqPerBin := float64(sampleRate) / float64(ps.fftFrameSize) + frameIndex := ps.latency + + // End jeeeez + + // for each channel individually + for c := 0; c < int(channels); c++ { + // convert this to a channel-specific float64 buffer + f64in := manip.BytesToF64(data, channels, bitDepth, c) + f64out := f64in + + for i := 0; i < len(f64in); i++ { + // Get a frame + ps.frame[frameIndex] = f64in[i] + // Bug here for early i values: they'll all be 0! + f64out[i] = ps.stack[frameIndex-ps.latency] + frameIndex++ + + // A full frame has been obtained + if frameIndex >= ps.fftFrameSize { + frameIndex = ps.latency + + // Windowing + for k := 0; k < ps.fftFrameSize; k++ { + ps.workBuffer[2*k] = ps.frame[k] * ps.window[k] + ps.workBuffer[(2*k)+1] = 0 + } + + ShortTimeFourierTransform(ps.workBuffer, ps.fftFrameSize, -1) + + // Analysis + for k := 0; k <= ps.fftFrameSize/2; k++ { + real := ps.workBuffer[2*k] + imag := ps.workBuffer[(2*k)+1] + + magn := 2 * math.Sqrt(real*real+imag*imag) + ps.magnitudes[k] = magn + + phase := math.Atan2(imag, real) + + diff := phase - ps.lastPhase[k] + ps.lastPhase[k] = phase + + diff -= float64(k) * ps.expected + + deltaPhase := int(diff * (1 / math.Pi)) + if deltaPhase >= 0 { + deltaPhase += deltaPhase & 1 + } else { + deltaPhase -= deltaPhase & 1 + } + + diff -= math.Pi * float64(deltaPhase) + diff *= float64(ps.oversampling) / (math.Pi * 2) + diff = (float64(k) + diff) * freqPerBin + + ps.frequencies[k] = diff + } + + // Processing + for k := 0; k < ps.fftFrameSize; k++ { + ps.synthMagnitudes[k] = 0 + ps.synthFrequencies[k] = 0 + } + + for k := 0; k < ps.fftFrameSize/2; k++ { + l := int(float64(k) * shiftBy) + if l < ps.fftFrameSize/2 { + ps.synthMagnitudes[l] += ps.magnitudes[k] + ps.synthFrequencies[l] = ps.frequencies[k] * shiftBy + } + } + + // Synthesis + for k := 0; k <= ps.fftFrameSize/2; k++ { + magn := ps.synthMagnitudes[k] + tmp := ps.synthFrequencies[k] + tmp -= float64(k) * freqPerBin + tmp /= freqPerBin + tmp *= 2 * math.Pi / float64(ps.oversampling) + tmp += float64(k) * ps.expected + ps.sumPhase[k] += tmp + + ps.workBuffer[2*k] = magn * math.Cos(ps.sumPhase[k]) + ps.workBuffer[(2*k)+1] = magn * math.Sin(ps.sumPhase[k]) + } + + // Remove negative frequencies + // I don't get how we know these ones are negative + // also this looks like it's going to overflow the slice + for k := ps.fftFrameSize + 2; k < 2*ps.fftFrameSize; k++ { + ps.workBuffer[k] = 0.0 + } + + ShortTimeFourierTransform(ps.workBuffer, ps.fftFrameSize, 1) + + // Windowing + for k := 0; k < ps.fftFrameSize; k++ { + ps.outAcc[k] += ps.windowFactors[k] * ps.workBuffer[2*k] + } + for k := 0; k < ps.step; k++ { + ps.stack[k] = ps.outAcc[k] + } + + // Shift accumulator, shift frame + for k := 0; k < ps.fftFrameSize; k++ { + ps.outAcc[k] = ps.outAcc[k+ps.step] + } + + for k := 0; k < ps.latency; k++ { + ps.frame[k] = ps.frame[k+ps.step] + } + } + } + // remap this f64in to the output + for i := c * int(byteDepth); i < len(data); i += int(byteDepth * 2) { + manip.SetInt16_f64(out, i, f64in[i/int(byteDepth*2)]) + } + } + datap := senc.GetData() + *datap = out + } +} + +// ShortTimeFourierTransform : FFT routine, (C)1996 S.M.Bernsee. Sign = -1 is FFT, 1 is iFFT (inverse) +// Fills fftBuffer[0...2*fftFrameSize-1] with the Fourier transform of the +// time domain data in fftBuffer[0...2*fftFrameSize-1]. The FFT array takes +// and returns the cosine and sine parts in an interleaved manner, ie. +// fftBuffer[0] = cosPart[0], fftBuffer[1] = sinPart[0], asf. fftFrameSize +// must be a power of 2. It expects a complex input signal (see footnote 2), +// ie. when working with 'common' audio signals our input signal has to be +// passed as {in[0],0.,in[1],0.,in[2],0.,...} asf. In that case, the transform +// of the frequencies of interest is in fftBuffer[0...fftFrameSize]. +func ShortTimeFourierTransform(data []float64, fftFrameSize, sign int) { + for i := 2; i < 2*(fftFrameSize-2); i += 2 { + j := 0 + for bitm := 2; bitm < 2*fftFrameSize; bitm <<= 1 { + if (i & bitm) != 0 { + j++ + } + j <<= 1 + } + if i < j { + data[j], data[i] = data[i], data[j] + data[j+1], data[i+1] = data[i+1], data[j+1] + } + } + max := int(math.Log(float64(fftFrameSize))/math.Log(2) + .5) + le := 2 + for k := 0; k < max; k++ { + le <<= 1 + le2 := le >> 1 + ur := 1.0 + ui := 0.0 + arg := math.Pi / float64(le2>>1) + wr := math.Cos(arg) + wi := float64(sign) * math.Sin(arg) + for j := 0; j < le2; j += 2 { + for i := j; i < 2*fftFrameSize; i += le { + tr := data[i+le2]*ur - data[i+le2+1]*ui + ti := data[i+le2]*ui + data[i+le2+1]*ur + data[i+le2] = data[i] - tr + data[i+le2+1] = data[i+1] - ti + data[i] += tr + data[i+1] += ti + } + tmp := ur*wr - ui*wi + ui = ur*wi + ui*wr + ur = tmp + } + } +} diff --git a/audio/klang/filter/resample.go b/audio/klang/filter/resample.go new file mode 100644 index 00000000..83bf2b8f --- /dev/null +++ b/audio/klang/filter/resample.go @@ -0,0 +1,26 @@ +package filter + +import ( + "fmt" + + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// Speed modifies the filtered audio by a speed ratio, changing its sample rate +// in the process while maintaining pitch. +func Speed(ratio float64, pitchShifter PitchShifter) Encoding { + return func(senc supports.Encoding) { + r := ratio + fmt.Println(ratio) + for r < .5 { + r *= 2 + pitchShifter.PitchShift(.5)(senc) + } + for r > 2.0 { + r /= 2 + pitchShifter.PitchShift(2.0)(senc) + } + pitchShifter.PitchShift(1 / r)(senc) + ModSampleRate(ratio)(senc.GetSampleRate()) + } +} diff --git a/audio/klang/filter/sampleRate.go b/audio/klang/filter/sampleRate.go new file mode 100644 index 00000000..6400f528 --- /dev/null +++ b/audio/klang/filter/sampleRate.go @@ -0,0 +1,27 @@ +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" +) + +// A SampleRate is a function that takes in uint32 SampleRates +type SampleRate func(*uint32) + +// Apply checks that the given audio supports SampleRate, filters if it +// can, then returns +func (srf SampleRate) Apply(a klang.Audio) (klang.Audio, error) { + if ssr, ok := a.(supports.SampleRate); ok { + srf(ssr.GetSampleRate()) + return a, nil + } + return a, supports.NewUnsupported([]string{"SampleRate"}) +} + +// ModSampleRate might slow down or speed up a sample, but this will +// effect the perceived pitch of the sample. See Speed. +func ModSampleRate(mult float64) SampleRate { + return func(sr *uint32) { + *sr = uint32(float64(*sr) * mult) + } +} diff --git a/audio/klang/filter/supports/supports.go b/audio/klang/filter/supports/supports.go new file mode 100644 index 00000000..11d2809e --- /dev/null +++ b/audio/klang/filter/supports/supports.go @@ -0,0 +1,39 @@ +// Package supports holds interface types for filter supports +package supports + +// Data types support filters that manipulate their raw audio data +type Data interface { + GetData() *[]byte +} + +// Loop types support filters that manipulate whether they loop +type Loop interface { + GetLoop() *bool +} + +// SampleRate types support filters that manipulate their SampleRate +type SampleRate interface { + GetSampleRate() *uint32 +} + +// BitDepth types support filters that manipulate bit depth. Probably +// only useful in combination as an encoding +type BitDepth interface { + GetBitDepth() *uint16 +} + +// Channels types support filters that manipulate channels. Probably +// only useful in combination as an encoding +type Channels interface { + GetChannels() *uint16 +} + +// Encoding types can get any variable on an audio.Encoding. They do +// not just return an audio.Encoding because that would be an import +// loop or another package to avoid said import loop. +type Encoding interface { + SampleRate + BitDepth + Data + Channels +} diff --git a/audio/klang/filter/supports/unsupported.go b/audio/klang/filter/supports/unsupported.go new file mode 100644 index 00000000..9fd2b875 --- /dev/null +++ b/audio/klang/filter/supports/unsupported.go @@ -0,0 +1,20 @@ +package supports + +// Unsupported is an error type reporting that a filter was not supported +// by the Audio type it was used on +type Unsupported struct { + filters []string +} + +// NewUnsupported returns an Unsupported error with the input filters +func NewUnsupported(filters []string) Unsupported { + return Unsupported{filters} +} + +func (un Unsupported) Error() string { + s := "Unsupported filters: " + for _, f := range un.filters { + s += f + " " + } + return s +} diff --git a/audio/klang/filter/volume.go b/audio/klang/filter/volume.go new file mode 100644 index 00000000..2146c5af --- /dev/null +++ b/audio/klang/filter/volume.go @@ -0,0 +1,66 @@ +package filter + +import ( + "github.com/oakmound/oak/v3/audio/klang/filter/supports" + "github.com/oakmound/oak/v3/audio/klang/manip" +) + +// Volume will magnify the data by mult, increasing or reducing the volume +// of the output sound. For mult <= 1 this should have no unexpected behavior, +// although for mult ~= 1 it might not have any effect. More importantly for +// mult > 1, values may result in the output data clipping over integer overflows, +// which is presumably not desired behavior. +func Volume(mult float64) Encoding { + return vol(0, 1, mult) +} + +// VolumeLeft acts like volume but reduces left channel volume only +func VolumeLeft(mult float64) Encoding { + return vol(0, 2, mult) +} + +// VolumeRight acts like volume but reduces left channel volume only +func VolumeRight(mult float64) Encoding { + return vol(1, 2, mult) +} + +func vol(init, inc int, mult float64) Encoding { + return mod(init, inc, func(f float64) float64 { + return f * mult + }) +} + +// VolumeBalance will filter audio on two channels such that the left channel +// is (l+r)/2 * lMult, and the right channel is (l+r)/2 * rMult +func VolumeBalance(lMult, rMult float64) Encoding { + return func(enc supports.Encoding) { + if *enc.GetChannels() != 2 { + return + } + data := enc.GetData() + d := *data + byteDepth := int(*enc.GetBitDepth() / 8) + switch byteDepth { + case 2: + for i := 0; i < len(d); i += (byteDepth * 2) { + var v int16 + var shift uint16 + for j := 0; j < byteDepth; j++ { + v += int16(int(d[i+j])+int(d[i+j+byteDepth])) / 2 << shift + shift += 8 + } + l := manip.Round(float64(v) * lMult) + r := manip.Round(float64(v) * rMult) + for j := 0; j < byteDepth; j++ { + d[i+j] = byte(l & 255) + d[i+j+byteDepth] = byte(r & 255) + l >>= 8 + r >>= 8 + } + } + default: + // log unsupported bit depth + } + *data = d + } +} diff --git a/audio/klang/format.go b/audio/klang/format.go new file mode 100644 index 00000000..04d1d271 --- /dev/null +++ b/audio/klang/format.go @@ -0,0 +1,29 @@ +package klang + +// Format stores the variables which are presumably +// constant for any given type of audio (wav / mp3 / flac ...) +type Format struct { + SampleRate uint32 + Channels uint16 + Bits uint16 +} + +// GetSampleRate satisfies supports.SampleRate +func (f *Format) GetSampleRate() *uint32 { + return &f.SampleRate +} + +// GetChannels satisfies supports.Channels +func (f *Format) GetChannels() *uint16 { + return &f.Channels +} + +// GetBitDepth satisfied supports.BitDepth +func (f *Format) GetBitDepth() *uint16 { + return &f.Bits +} + +// Wave takes in raw bytes and encodes them according to this format +func (f *Format) Wave(b []byte) (Audio, error) { + return EncodeBytes(Encoding{b, *f, CanLoop{}}) +} diff --git a/audio/klang/manip/convert.go b/audio/klang/manip/convert.go new file mode 100644 index 00000000..8fa912a1 --- /dev/null +++ b/audio/klang/manip/convert.go @@ -0,0 +1,10 @@ +package manip + +func BytesToF64(data []byte, channels, bitRate uint16, channel int) []float64 { + byteDepth := bitRate / 8 + out := make([]float64, (len(data)/int(byteDepth*channels))+1) + for i := channel * int(byteDepth); i < len(data); i += int(byteDepth * channels) { + out[i/int(byteDepth*channels)] = GetFloat64(data, i, byteDepth) + } + return out +} diff --git a/audio/klang/manip/math.go b/audio/klang/manip/math.go new file mode 100644 index 00000000..838774a6 --- /dev/null +++ b/audio/klang/manip/math.go @@ -0,0 +1,38 @@ +package manip + +func SetInt16(d []byte, i int, in int64) { + for j := 0; j < 2; j++ { + d[i+j] = byte(in & 255) + in >>= 8 + } +} + +func GetInt16(d []byte, i int) (out int16) { + var shift uint16 + for j := 0; j < 2; j++ { + out += int16(d[i+j]) << shift + shift += 8 + } + return +} + +func GetFloat64(d []byte, i int, byteDepth uint16) float64 { + switch byteDepth { + case 1: + return float64(int8(d[i])) / 128.0 + case 2: + return float64(GetInt16(d, i)) / 32768.0 + } + return 0.0 +} + +func SetInt16_f64(d []byte, i int, in float64) { + SetInt16(d, i, int64(in*32768)) +} + +func Round(f float64) int64 { + if f < 0 { + return int64(f - .5) + } + return int64(f + .5) +} diff --git a/audio/klang/multi.go b/audio/klang/multi.go new file mode 100644 index 00000000..8f6726bd --- /dev/null +++ b/audio/klang/multi.go @@ -0,0 +1,108 @@ +package klang + +import ( + "errors" + "time" +) + +// A Multi lets lists of audios be used simultaneously +type Multi struct { + Audios []Audio +} + +// NewMulti returns a new multi +func NewMulti(as ...Audio) *Multi { + return &Multi{Audios: as} +} + +// Play plays all audios in the Multi ASAP +func (m *Multi) Play() <-chan error { + extCh := make(chan error) + go func() { + // Todo: Propagating N errors? + for _, a := range m.Audios { + a.Play() + } + extCh <- nil + }() + return extCh +} + +// Filter applies all the given filters on everything in the Multi +func (m *Multi) Filter(fs ...Filter) (Audio, error) { + var err, consErr error + for i, a := range m.Audios { + m.Audios[i], err = a.Filter(fs...) + if err != nil { + consErr = errors.New(err.Error() + ":" + consErr.Error()) + } + } + return m, consErr +} + +// MustFilter acts like filter but ignores errors. +func (m *Multi) MustFilter(fs ...Filter) Audio { + a, _ := m.Filter(fs...) + return a +} + +func (m *Multi) SetVolume(vol int32) error { + for _, a := range m.Audios { + err := a.SetVolume(vol) + if err != nil { + return err + } + } + return nil +} + +// Stop stops all audios in the Multi. Any that fail will report an error. +func (m *Multi) Stop() error { + var err, consErr error + for _, a := range m.Audios { + err = a.Stop() + if err != nil { + if consErr == nil { + consErr = err + } else { + consErr = errors.New(err.Error() + ":" + consErr.Error()) + } + } + } + return consErr +} + +// Copy returns a copy of this Multi +func (m *Multi) Copy() (Audio, error) { + var err error + newAudios := make([]Audio, len(m.Audios)) + for i, a := range m.Audios { + newAudios[i], err = a.Copy() + if err != nil { + return nil, err + } + } + return &Multi{newAudios}, nil + +} + +// MustCopy acts like Copy but panics if error != nil +func (m *Multi) MustCopy() Audio { + m2, err := m.Copy() + if err != nil { + panic(err) + } + return m2 +} + +// PlayLength returns how long this audio will play for +func (m *Multi) PlayLength() time.Duration { + var d time.Duration + for _, a := range m.Audios { + d2 := a.PlayLength() + if d < d2 { + d = d2 + } + } + return d +} diff --git a/audio/klang/skip_devices.go b/audio/klang/skip_devices.go new file mode 100644 index 00000000..b9008725 --- /dev/null +++ b/audio/klang/skip_devices.go @@ -0,0 +1,19 @@ +package klang + +import ( + "os" +) + +// SkipDevicesContaining is a environment variable controlled value +// which will cause audio devices containing the given string to be +// skipped when finding an audio device to play audio through. +// Currently only supported on linux. +// Todo: find a more elegant fix for bad audio devices being chosen +var SkipDevicesContaining = "HDMI" + +func init() { + skipDevices := os.Getenv("KGS_AUDIO_SKIP_DEVICES") + if skipDevices != "" { + SkipDevicesContaining = skipDevices + } +} diff --git a/audio/load.go b/audio/load.go index af68f651..640db755 100644 --- a/audio/load.go +++ b/audio/load.go @@ -4,9 +4,9 @@ import ( "path/filepath" "strings" - "github.com/200sc/klangsynthese/audio" - "github.com/200sc/klangsynthese/mp3" - "github.com/200sc/klangsynthese/wav" + audio "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/mp3" + "github.com/oakmound/oak/v3/audio/wav" "golang.org/x/sync/errgroup" "github.com/oakmound/oak/v3/dlog" @@ -122,7 +122,6 @@ func batchLoad(baseFolder string, blankOut bool) error { files, err := fileutil.ReadDir(baseFolder) if err != nil { - dlog.Error(err) return err } @@ -143,7 +142,6 @@ func batchLoad(baseFolder string, blankOut bool) error { _, err = Load(baseFolder, n) } if err != nil { - dlog.Error(err) return err } return nil @@ -154,7 +152,6 @@ func batchLoad(baseFolder string, blankOut bool) error { } } err = eg.Wait() - dlog.Verb("Loading complete") return err } diff --git a/audio/mp3/mp3.go b/audio/mp3/mp3.go new file mode 100644 index 00000000..22e02f43 --- /dev/null +++ b/audio/mp3/mp3.go @@ -0,0 +1,40 @@ +// Package mp3 provides functionality to handle .mp3 files and .mp3 encoded data +package mp3 + +import ( + "bytes" + "errors" + "io" + + audio "github.com/oakmound/oak/v3/audio/klang" + + haj "github.com/hajimehoshi/go-mp3" +) + +// Load loads an mp3-encoded reader into an audio +func Load(r io.ReadCloser) (audio.Audio, error) { + d, err := haj.NewDecoder(r) + if err != nil { + return nil, err + } + buf := bytes.NewBuffer(make([]byte, 0, d.Length())) + _, err = io.Copy(buf, d) + if err != nil { + return nil, err + } + mformat := audio.Format{ + SampleRate: uint32(d.SampleRate()), + Bits: 16, + Channels: 2, + } + return audio.EncodeBytes( + audio.Encoding{ + Data: buf.Bytes(), + Format: mformat, + }) +} + +// Save will eventually save an audio encoded as an MP3 to r +func Save(r io.ReadWriter, a audio.Audio) error { + return errors.New("Unsupported Functionality") +} diff --git a/audio/play.go b/audio/play.go index c20f6c25..ee0b485f 100644 --- a/audio/play.go +++ b/audio/play.go @@ -1,7 +1,7 @@ package audio import ( - "github.com/200sc/klangsynthese/font" + "github.com/oakmound/oak/v3/audio/font" "github.com/oakmound/oak/v3/dlog" ) diff --git a/audio/posFilter.go b/audio/posFilter.go index dc9748f6..7efa15ed 100644 --- a/audio/posFilter.go +++ b/audio/posFilter.go @@ -1,9 +1,9 @@ package audio import ( - "github.com/200sc/klangsynthese/audio" - "github.com/200sc/klangsynthese/audio/filter" - "github.com/200sc/klangsynthese/audio/filter/supports" + "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/klang/filter" + "github.com/oakmound/oak/v3/audio/klang/filter/supports" "github.com/oakmound/oak/v3/physics" ) @@ -16,7 +16,7 @@ type SupportsPos interface { } var ( - _ audio.Filter = Pos(func(SupportsPos) {}) + _ klang.Filter = Pos(func(SupportsPos) {}) ) // Pos functions are filters that require a SupportsPos interface @@ -24,7 +24,7 @@ type Pos func(SupportsPos) // Apply is a function allowing Pos to satisfy the audio.Filter interface. // Pos applies itself to any audio it is given that supports it. -func (xp Pos) Apply(a audio.Audio) (audio.Audio, error) { +func (xp Pos) Apply(a klang.Audio) (klang.Audio, error) { if sxp, ok := a.(SupportsPos); ok { xp(sxp) return a, nil diff --git a/audio/posFilter_test.go b/audio/posFilter_test.go index 1a426330..05f49eeb 100644 --- a/audio/posFilter_test.go +++ b/audio/posFilter_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/200sc/klangsynthese/font" - "github.com/200sc/klangsynthese/synth" + "github.com/oakmound/oak/v3/audio/font" + "github.com/oakmound/oak/v3/audio/synth" ) func TestPosFilter(t *testing.T) { diff --git a/audio/sequence/chordPattern.go b/audio/sequence/chordPattern.go new file mode 100644 index 00000000..900cdc25 --- /dev/null +++ b/audio/sequence/chordPattern.go @@ -0,0 +1,36 @@ +package sequence + +import ( + "time" + + "github.com/oakmound/oak/v3/audio/synth" +) + +// A ChordPattern represents the order of pitches and holds +// for each of those pitches over a sequence of (potential) +// chords. Todo: pitchPattern is a subset of this, should +// it even exist? +type ChordPattern struct { + Pitches [][]synth.Pitch + Holds [][]time.Duration +} + +// HasChords lets generators be built from chord Options +// if they have a pointer to a chord pattern +type HasChords interface { + GetChordPattern() *ChordPattern +} + +// GetChordPattern returns a pointer to a generator's chord pattern +func (cp *ChordPattern) GetChordPattern() *ChordPattern { + return cp +} + +// Chords sets the generator's chord pattern +func Chords(cp ChordPattern) Option { + return func(g Generator) { + if hcp, ok := g.(HasChords); ok { + *(hcp.GetChordPattern()) = cp + } + } +} diff --git a/audio/sequence/generator.go b/audio/sequence/generator.go new file mode 100644 index 00000000..edbe635c --- /dev/null +++ b/audio/sequence/generator.go @@ -0,0 +1,20 @@ +package sequence + +// A Generator stores settings to create a sequence +type Generator interface { + Generate() *Sequence +} + +// Option types are inserted into Constructors to create generators +type Option func(Generator) + +// And combines any number of options into a single option. +// And is a reminder that you can store combined settings to avoid +// having to rewrite them +func And(opts ...Option) Option { + return func(g Generator) { + for _, opt := range opts { + opt(g) + } + } +} diff --git a/audio/sequence/holdPattern.go b/audio/sequence/holdPattern.go new file mode 100644 index 00000000..df0e02f7 --- /dev/null +++ b/audio/sequence/holdPattern.go @@ -0,0 +1,76 @@ +package sequence + +import "time" + +// A HoldPattern is a pattern that might loop on itself for how long notes +// should be held +type HoldPattern []time.Duration + +// HasHolds enables generators to be built from HoldPattern and use the +// related option functions +type HasHolds interface { + GetHoldPattern() *[]time.Duration +} + +// GetHoldPattern lets composing HoldPattern satisfy HasHolds +func (hp *HoldPattern) GetHoldPattern() *HoldPattern { + return hp +} + +// Holds sets the generator's Hold pattern +func Holds(vs ...time.Duration) Option { + return func(g Generator) { + if hhs, ok := g.(HasHolds); ok { + *hhs.GetHoldPattern() = vs + } + } +} + +// HoldAt sets the n'th value in the entire play sequence +// to be Hold p. This could involve duplicating a pattern +// until it is long enough to reach n. Meaningless if the +// Hold pattern has not been set yet. +func HoldAt(t time.Duration, n int) Option { + return func(g Generator) { + if hhs, ok := g.(HasHolds); ok { + if hl, ok := hhs.(HasLength); ok { + if hl.GetLength() < n { + hp := hhs.GetHoldPattern() + Holds := *hp + if len(Holds) == 0 { + return + } + // If the pattern is not long enough, there are two things + // we could do-- 1. Extend the pattern and replace the + // individual note, or 2. Replace the note that would be + // played at n and thus all earlier and later plays within + // the pattern as well. + // + // This uses approach 1. + for len(Holds) <= n { + Holds = append(Holds, Holds...) + } + Holds[n] = t + *hp = Holds + } + } + } + } +} + +// HoldPatternAt sets the n'th value in the Hold pattern +// to be Hold p. Meaningless if the Hold pattern has not +// been set yet. +func HoldPatternAt(t time.Duration, n int) Option { + return func(g Generator) { + if hhs, ok := g.(HasHolds); ok { + hp := hhs.GetHoldPattern() + Holds := *hp + if len(Holds) <= n { + return + } + Holds[n] = t + *hp = Holds + } + } +} diff --git a/audio/sequence/length.go b/audio/sequence/length.go new file mode 100644 index 00000000..c923d6ed --- /dev/null +++ b/audio/sequence/length.go @@ -0,0 +1,24 @@ +package sequence + +type Length int + +type HasLength interface { + GetLength() int + SetLength(int) +} + +func (l *Length) GetLength() int { + return int(*l) +} + +func (l *Length) SetLength(i int) { + *l = Length(i) +} + +func PlayLength(i int) Option { + return func(g Generator) { + if l, ok := g.(HasLength); ok { + l.SetLength(i) + } + } +} diff --git a/audio/sequence/loop.go b/audio/sequence/loop.go new file mode 100644 index 00000000..4c42eee0 --- /dev/null +++ b/audio/sequence/loop.go @@ -0,0 +1,25 @@ +package sequence + +type Loop bool + +type HasLoops interface { + GetLoop() bool + SetLoop(bool) +} + +func (l *Loop) GetLoop() bool { + return bool(*l) +} + +func (l *Loop) SetLoop(b bool) { + *l = Loop(b) +} + +// Loops sets the generator's Loop +func Loops(b bool) Option { + return func(g Generator) { + if ht, ok := g.(HasLoops); ok { + ht.SetLoop(b) + } + } +} diff --git a/audio/sequence/pitchPattern.go b/audio/sequence/pitchPattern.go new file mode 100644 index 00000000..2d6d8e15 --- /dev/null +++ b/audio/sequence/pitchPattern.go @@ -0,0 +1,74 @@ +package sequence + +import "github.com/oakmound/oak/v3/audio/synth" + +type PitchPattern []synth.Pitch + +type HasPitches interface { + GetPitchPattern() []synth.Pitch + SetPitchPattern([]synth.Pitch) +} + +func (pp *PitchPattern) GetPitchPattern() []synth.Pitch { + return *pp +} + +func (pp *PitchPattern) SetPitchPattern(ps []synth.Pitch) { + *pp = ps +} + +// Pitches sets the generator's pitch pattern +func Pitches(ps ...synth.Pitch) Option { + return func(g Generator) { + if hpp, ok := g.(HasPitches); ok { + hpp.SetPitchPattern(ps) + } + } +} + +// PitchAt sets the n'th value in the entire play sequence +// to be pitch p. This could involve duplicating a pattern +// until it is long enough to reach n. Meaningless if the +// pitch pattern has not been set yet. +func PitchAt(p synth.Pitch, n int) Option { + return func(g Generator) { + if hpp, ok := g.(HasPitches); ok { + if hl, ok := hpp.(HasLength); ok { + if hl.GetLength() < n { + pitches := hpp.GetPitchPattern() + if len(pitches) == 0 { + return + } + // If the pattern is not long enough, there are two things + // we could do-- 1. Extend the pattern and replace the + // individual note, or 2. Replace the note that would be + // played at n and thus all earlier and later plays within + // the pattern as well. + // + // This uses approach 1. + for len(pitches) < n { + pitches = append(pitches, pitches...) + } + pitches[n] = p + hpp.SetPitchPattern(pitches) + } + } + } + } +} + +// PitchPatternAt sets the n'th value in the pitch pattern +// to be pitch p. Meaningless if the pitch pattern has not +// been set yet. +func PitchPatternAt(p synth.Pitch, n int) Option { + return func(g Generator) { + if hpp, ok := g.(HasPitches); ok { + pitches := hpp.GetPitchPattern() + if len(pitches) < n { + return + } + pitches[n] = p + hpp.SetPitchPattern(pitches) + } + } +} diff --git a/audio/sequence/sequence.go b/audio/sequence/sequence.go new file mode 100644 index 00000000..9ee6606f --- /dev/null +++ b/audio/sequence/sequence.go @@ -0,0 +1,172 @@ +// Package sequence provides generators and options for creating audio sequences +package sequence + +import ( + "errors" + "time" + + audio "github.com/oakmound/oak/v3/audio/klang" +) + +// A Sequence is a timed pattern of simultaneously played audios. +type Sequence struct { + // Sequences play patterns of audio + // everything at Pattern[0] will be simultaneously Play()ed at + // Sequence.Play() + Pattern []*audio.Multi + patternIndex int + // Every tick, the next index in Pattern will be played by a Sequence + // until the pattern is over. + Ticker *time.Ticker + // needed to copy Ticker + // consider: replacing ticker with dynamic ticker + tickDuration time.Duration + stopCh chan error + loop bool +} + +// Play on a sequence plays the pattern encoded in the sequence until stopped +func (s *Sequence) Play() <-chan error { + ch := make(chan error) + go func() { + for { + s.patternIndex = 0 + for s.patternIndex < len(s.Pattern) { + s.Pattern[s.patternIndex].Play() + select { + case <-s.stopCh: + s.stopCh <- s.Pattern[s.patternIndex].Stop() + ch <- nil + return + case <-s.Ticker.C: + } + s.patternIndex++ + } + if !s.loop { + ch <- nil + return + } + } + }() + return ch +} + +// Filter for a sequence does nothing yet +func (s *Sequence) Filter(fs ...audio.Filter) (audio.Audio, error) { + // Filter on a sequence just applies the filter to all audios.. + // but it can't do that always, what if the filter is Loop? + // this implies two kinds of filters? + // this doesn't work because FIlter is not an interface + // for _, f := range fs { + // if _, ok := f.(audio.Loop); ok { + // s.loop = true + // } else if _, ok := f.(audio.NoLoop); ok { + // s.loop = false + // } else { + // for _, col := range s.Pattern { + // for _, a := range col { + // a.Filter(f) + // } + // } + // } + // } + return s, nil +} + +func (s *Sequence) SetVolume(int32) error { + return errors.New("unsupported") +} + +// MustFilter acts as filter, but does not respect errors. +func (s *Sequence) MustFilter(fs ...audio.Filter) audio.Audio { + a, _ := s.Filter(fs...) + return a +} + +// Stop stops a sequence +func (s *Sequence) Stop() error { + s.stopCh <- nil + return <-s.stopCh +} + +// Copy copies a sequence +func (s *Sequence) Copy() (audio.Audio, error) { + var err error + s2 := &Sequence{ + Pattern: make([]*audio.Multi, len(s.Pattern)), + Ticker: time.NewTicker(s.tickDuration), + tickDuration: s.tickDuration, + stopCh: make(chan error), + loop: s.loop, + } + for i := range s2.Pattern { + s2.Pattern[i] = new(audio.Multi) + s2.Pattern[i].Audios = make([]audio.Audio, len(s.Pattern[i].Audios)) + for j := range s2.Pattern[i].Audios { + // This could make a sequence that reuses the same + // audio use a lot more memory when copied-- a better route + // would involve identifying all unique audios + // and making a copy for each of those, but that + // requires producing unique IDs for each audio + // (which would probably be a hash of their encoding? + // but that raises issues for audios that don't want + // to follow real encoding rules (like this one!)) + s2.Pattern[i].Audios[j], err = s.Pattern[i].Audios[j].Copy() + if err != nil { + return nil, err + } + } + } + return s2, nil +} + +// MustCopy acts as copy but panics on errors +func (s *Sequence) MustCopy() audio.Audio { + a, err := s.Copy() + if err != nil { + panic(err) + } + return a +} + +// PlayLength returns how long this sequence will play before looping or stopping. +// This does not include how long the last note is held beyond the tick duration +func (s *Sequence) PlayLength() time.Duration { + return time.Duration(len(s.Pattern)) * s.tickDuration +} + +// Mix combines two sequences +func (s *Sequence) Mix(s2 *Sequence) (*Sequence, error) { + // Todo: we should be able to combine not-too-disparate + // sequences like one that ticks on .5 seconds and one that ticks + // on .25 seconds + if s.tickDuration != s2.tickDuration { + return nil, errors.New("Incompatible sequences") + } + seq, err := s.Copy() + if err != nil { + return nil, err + } + s3 := seq.(*Sequence) + for i, col := range s2.Pattern { + s3.Pattern[i].Audios = append(s3.Pattern[i].Audios, col.Audios...) + } + return s3, nil +} + +// Append creates a sequence by combining two sequences in order +func (s *Sequence) Append(s2 *Sequence) (*Sequence, error) { + // Todo: we should be able to combine not-too-disparate + // sequences like one that ticks on .5 seconds and one that ticks + // on .25 seconds + if s.tickDuration != s2.tickDuration { + return nil, errors.New("Incompatible sequences") + } + seq, err := s.Copy() + if err != nil { + return nil, err + } + s3 := seq.(*Sequence) + s3.Pattern = append(s3.Pattern, s2.Pattern...) + return s3, nil +} diff --git a/audio/sequence/tick.go b/audio/sequence/tick.go new file mode 100644 index 00000000..a28a4e82 --- /dev/null +++ b/audio/sequence/tick.go @@ -0,0 +1,27 @@ +package sequence + +import "time" + +type Tick time.Duration + +type HasTicks interface { + GetTick() time.Duration + SetTick(time.Duration) +} + +func (vp *Tick) GetTick() time.Duration { + return time.Duration(*vp) +} + +func (vp *Tick) SetTick(vs time.Duration) { + *vp = Tick(vs) +} + +// Ticks sets the generator's Tick +func Ticks(t time.Duration) Option { + return func(g Generator) { + if ht, ok := g.(HasTicks); ok { + ht.SetTick(t) + } + } +} diff --git a/audio/sequence/volumePattern.go b/audio/sequence/volumePattern.go new file mode 100644 index 00000000..adadd065 --- /dev/null +++ b/audio/sequence/volumePattern.go @@ -0,0 +1,72 @@ +package sequence + +type VolumePattern []float64 + +type HasVolumes interface { + GetVolumePattern() []float64 + SetVolumePattern([]float64) +} + +func (vp *VolumePattern) GetVolumePattern() []float64 { + return *vp +} + +func (vp *VolumePattern) SetVolumePattern(vs []float64) { + *vp = vs +} + +// Volumes sets the generator's Volume pattern +func Volumes(vs ...float64) Option { + return func(g Generator) { + if hvs, ok := g.(HasVolumes); ok { + hvs.SetVolumePattern(vs) + } + } +} + +// VolumeAt sets the n'th value in the entire play sequence +// to be Volume p. This could involve duplicating a pattern +// until it is long enough to reach n. Meaningless if the +// Volume pattern has not been set yet. +func VolumeAt(v float64, n int) Option { + return func(g Generator) { + if hvs, ok := g.(HasVolumes); ok { + if hl, ok := hvs.(HasLength); ok { + if hl.GetLength() < n { + volumes := hvs.GetVolumePattern() + if len(volumes) == 0 { + return + } + // If the pattern is not long enough, there are two things + // we could do-- 1. Extend the pattern and replace the + // individual note, or 2. Replace the note that would be + // played at n and thus all earlier and later plays within + // the pattern as well. + // + // This uses approach 1. + for len(volumes) < n { + volumes = append(volumes, volumes...) + } + volumes[n] = v + hvs.SetVolumePattern(volumes) + } + } + } + } +} + +// VolumePatternAt sets the n'th value in the Volume pattern +// to be Volume p. Meaningless if the Volume pattern has not +// been set yet. +func VolumePatternAt(v float64, n int) Option { + return func(g Generator) { + if hvs, ok := g.(HasVolumes); ok { + volumes := hvs.GetVolumePattern() + if len(volumes) < n { + return + } + volumes[n] = v + hvs.SetVolumePattern(volumes) + } + } +} diff --git a/audio/sequence/waveFunction.go b/audio/sequence/waveFunction.go new file mode 100644 index 00000000..0e76bac6 --- /dev/null +++ b/audio/sequence/waveFunction.go @@ -0,0 +1,74 @@ +package sequence + +import "github.com/oakmound/oak/v3/audio/synth" + +type WavePattern []synth.Wave + +type HasWaves interface { + GetWavePattern() []synth.Wave + SetWavePattern([]synth.Wave) +} + +func (wp *WavePattern) GetWavePattern() []synth.Wave { + return *wp +} + +func (wp *WavePattern) SetWavePattern(ws []synth.Wave) { + *wp = ws +} + +// Waves sets the generator's Wave pattern +func Waves(ws ...synth.Wave) Option { + return func(g Generator) { + if hw, ok := g.(HasWaves); ok { + hw.SetWavePattern(ws) + } + } +} + +// WaveAt sets the n'th value in the entire play sequence +// to be Wave p. This could involve duplicating a pattern +// until it is long enough to reach n. Meaningless if the +// Wave pattern has not been set yet. +func WaveAt(w synth.Wave, n int) Option { + return func(g Generator) { + if hw, ok := g.(HasWaves); ok { + if hl, ok := hw.(HasLength); ok { + if hl.GetLength() < n { + Waves := hw.GetWavePattern() + if len(Waves) == 0 { + return + } + // If the pattern is not long enough, there are two things + // we could do-- 1. Extend the pattern and replace the + // individual note, or 2. Replace the note that would be + // played at n and thus all earlier and later plays within + // the pattern as well. + // + // This uses approach 1. + for len(Waves) < n { + Waves = append(Waves, Waves...) + } + Waves[n] = w + hw.SetWavePattern(Waves) + } + } + } + } +} + +// WavePatternAt sets the n'th value in the Wave pattern +// to be Wave p. Meaningless if the Wave pattern has not +// been set yet. +func WavePatternAt(w synth.Wave, n int) Option { + return func(g Generator) { + if hw, ok := g.(HasWaves); ok { + Waves := hw.GetWavePattern() + if len(Waves) < n { + return + } + Waves[n] = w + hw.SetWavePattern(Waves) + } + } +} diff --git a/audio/sequence/waveGenerator.go b/audio/sequence/waveGenerator.go new file mode 100644 index 00000000..d51d32fa --- /dev/null +++ b/audio/sequence/waveGenerator.go @@ -0,0 +1,93 @@ +package sequence + +import ( + "time" + + audio "github.com/oakmound/oak/v3/audio/klang" + "github.com/oakmound/oak/v3/audio/synth" +) + +// A WaveGenerator composes sets of simple waveforms as a sequence +type WaveGenerator struct { + ChordPattern + PitchPattern + WavePattern + VolumePattern + HoldPattern + Length + Tick + Loop +} + +// NewWaveGenerator uses optional variadic syntax to enable +// any variant of a generator to be made +func NewWaveGenerator(opts ...Option) *WaveGenerator { + wg := &WaveGenerator{} + for _, opt := range opts { + opt(wg) + } + return wg +} + +// Generate generates a sequence from this wave generator +func (wg *WaveGenerator) Generate() *Sequence { + sq := &Sequence{} + sq.Ticker = time.NewTicker(time.Duration(wg.Tick)) + sq.tickDuration = time.Duration(wg.Tick) + sq.loop = bool(wg.Loop) + sq.stopCh = make(chan error) + if wg.Length == 0 { + if len(wg.PitchPattern) != 0 { + wg.Length = Length(len(wg.PitchPattern)) + } else if len(wg.ChordPattern.Pitches) != 0 { + wg.Length = Length(len(wg.ChordPattern.Pitches)) + } + // else whoops, there's no length + } + if len(wg.HoldPattern) == 0 { + wg.HoldPattern = []time.Duration{sq.tickDuration} + } + sq.Pattern = make([]*audio.Multi, wg.Length) + + volumeIndex := 0 + waveIndex := 0 + if len(wg.PitchPattern) != 0 { + pitchIndex := 0 + holdIndex := 0 + for i := range sq.Pattern { + p := wg.PitchPattern[pitchIndex] + if p != synth.Rest { + a, _ := wg.WavePattern[waveIndex]( + synth.AtPitch(p), + synth.Duration(wg.HoldPattern[holdIndex]), + synth.Volume(wg.VolumePattern[volumeIndex]), + ) + sq.Pattern[i] = audio.NewMulti(a) + } else { + sq.Pattern[i] = audio.NewMulti() + } + pitchIndex = (pitchIndex + 1) % len(wg.PitchPattern) + volumeIndex = (volumeIndex + 1) % len(wg.VolumePattern) + waveIndex = (waveIndex + 1) % len(wg.WavePattern) + holdIndex = (holdIndex + 1) % len(wg.HoldPattern) + } + } else if len(wg.ChordPattern.Pitches) != 0 { + chordIndex := 0 + for i := range sq.Pattern { + mult := audio.NewMulti() + for j, p := range wg.ChordPattern.Pitches[chordIndex] { + a, _ := wg.WavePattern[waveIndex]( + synth.AtPitch(p), + synth.Duration(wg.ChordPattern.Holds[chordIndex][j]), + synth.Volume(wg.VolumePattern[volumeIndex]), + ) + mult.Audios = append(mult.Audios, a) + } + sq.Pattern[i] = mult + waveIndex = (waveIndex + 1) % len(wg.WavePattern) + volumeIndex = (volumeIndex + 1) % len(wg.VolumePattern) + chordIndex = (chordIndex + 1) % len(wg.ChordPattern.Pitches) + } + } + return sq +} diff --git a/audio/signal.go b/audio/signal.go deleted file mode 100644 index 052153a8..00000000 --- a/audio/signal.go +++ /dev/null @@ -1,45 +0,0 @@ -package audio - -// A ChannelSignal is sent to an AudioChannel to indicate when they should -// attempt to play an audio sound -type ChannelSignal interface { - GetIndex() int - GetPos() (bool, float64, float64) -} - -// Signal is a default ChannelSignal that just indicates what audio from a set -// of audio data should be played by int index -type Signal struct { - Index int -} - -// GetIndex returns Signal.Index -func (s Signal) GetIndex() int { - return s.Index -} - -// GetPos returns that a Signal is not positional -func (s Signal) GetPos() (bool, float64, float64) { - return false, 0, 0 -} - -// PosSignal is a ChannelSignal compatible with Ears -type PosSignal struct { - Signal - X, Y float64 -} - -// NewPosSignal constructs a PosSignal -func NewPosSignal(index int, x, y float64) PosSignal { - return PosSignal{ - Signal{index}, - x, - y, - } -} - -// GetPos returns the floating points passed into a PosSignal -// as the origin of a sound to be heard -func (ps PosSignal) GetPos() (bool, float64, float64) { - return true, ps.X, ps.Y -} diff --git a/audio/signal_test.go b/audio/signal_test.go deleted file mode 100644 index 2ad6cb40..00000000 --- a/audio/signal_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package audio - -import "testing" - -func TestPosSignal(t *testing.T) { - psgn := NewPosSignal(1, 2, 3) - ok, x, y := psgn.GetPos() - if !ok { - t.Fatalf("expected getPos to return true") - } - if x != 2 { - t.Fatalf("expected x of %v, got %v", 2, x) - } - if y != 3 { - t.Fatalf("expected y of %v, got %v", 3, x) - } -} diff --git a/audio/synth/option.go b/audio/synth/option.go new file mode 100644 index 00000000..59300db7 --- /dev/null +++ b/audio/synth/option.go @@ -0,0 +1,52 @@ +package synth + +import "time" + +// Option types modify waveform sources before they generate a waveform +type Option func(Source) Source + +// Duration sets the duration of a generated waveform +func Duration(t time.Duration) Option { + return func(s Source) Source { + s.Seconds = t.Seconds() + return s + } +} + +// Volume sets the volume of a generated waveform. It guarantees that 0 <= v <= 1 +// (silent <= v <= max volume) +func Volume(v float64) Option { + return func(s Source) Source { + if v > 1.0 { + v = 1.0 + } else if v < 0 { + v = 0 + } + s.Volume = v + return s + } +} + +// AtPitch sets the pitch of a generated waveform. +func AtPitch(p Pitch) Option { + return func(s Source) Source { + s.Pitch = p + return s + } +} + +// Mono sets the format to play mono audio. +func Mono() Option { + return func(s Source) Source { + s.Channels = 1 + return s + } +} + +// Stereo sets the format to play stereo audio. +func Stereo() Option { + return func(s Source) Source { + s.Channels = 2 + return s + } +} diff --git a/audio/synth/pitch.go b/audio/synth/pitch.go new file mode 100644 index 00000000..bf6b0105 --- /dev/null +++ b/audio/synth/pitch.go @@ -0,0 +1,425 @@ +package synth + +// A Pitch is a helper type for synth functions so +// a user can write A4 instead of a frequency value +// for a desired tone +type Pitch uint16 + +// Pitch frequencies +// Values taken from http://peabody.sapp.org/class/st2/lab/notehz/ +const ( + Rest Pitch = 0 + C0 Pitch = 16 + C0s Pitch = 17 + D0b Pitch = 17 + D0 Pitch = 18 + D0s Pitch = 20 + E0b Pitch = 20 + E0 Pitch = 21 + F0 Pitch = 22 + F0s Pitch = 23 + G0b Pitch = 23 + G0 Pitch = 25 + G0s Pitch = 26 + A0b Pitch = 26 + A0 Pitch = 28 + A0s Pitch = 29 + B0b Pitch = 29 + B0 Pitch = 31 + C1 Pitch = 33 + C1s Pitch = 35 + D1b Pitch = 35 + D1 Pitch = 37 + D1s Pitch = 39 + E1b Pitch = 39 + E1 Pitch = 41 + F1 Pitch = 44 + F1s Pitch = 46 + G1b Pitch = 46 + G1 Pitch = 49 + G1s Pitch = 52 + A1b Pitch = 52 + A1 Pitch = 55 + A1s Pitch = 58 + B1b Pitch = 58 + B1 Pitch = 62 + C2 Pitch = 65 + C2s Pitch = 69 + D2b Pitch = 69 + D2 Pitch = 73 + D2s Pitch = 78 + E2b Pitch = 78 + E2 Pitch = 82 + F2 Pitch = 87 + F2s Pitch = 93 + G2b Pitch = 93 + G2 Pitch = 98 + G2s Pitch = 104 + A2b Pitch = 104 + A2 Pitch = 110 + A2s Pitch = 117 + B2b Pitch = 117 + B2 Pitch = 124 + C3 Pitch = 131 + C3s Pitch = 139 + D3b Pitch = 139 + D3 Pitch = 147 + D3s Pitch = 156 + E3b Pitch = 156 + E3 Pitch = 165 + F3 Pitch = 175 + F3s Pitch = 185 + G3b Pitch = 185 + G3 Pitch = 196 + G3s Pitch = 208 + A3b Pitch = 208 + A3 Pitch = 220 + A3s Pitch = 233 + B3b Pitch = 233 + B3 Pitch = 247 + C4 Pitch = 262 + C4s Pitch = 278 + D4b Pitch = 278 + D4 Pitch = 294 + D4s Pitch = 311 + E4b Pitch = 311 + E4 Pitch = 330 + F4 Pitch = 349 + F4s Pitch = 370 + G4b Pitch = 370 + G4 Pitch = 392 + G4s Pitch = 415 + A4b Pitch = 415 + A4 Pitch = 440 + A4s Pitch = 466 + B4b Pitch = 466 + B4 Pitch = 494 + C5 Pitch = 523 + C5s Pitch = 554 + D5b Pitch = 554 + D5 Pitch = 587 + D5s Pitch = 622 + E5b Pitch = 622 + E5 Pitch = 659 + F5 Pitch = 699 + F5s Pitch = 740 + G5b Pitch = 740 + G5 Pitch = 784 + G5s Pitch = 831 + A5b Pitch = 831 + A5 Pitch = 880 + A5s Pitch = 932 + B5b Pitch = 932 + B5 Pitch = 988 + C6 Pitch = 1047 + C6s Pitch = 1109 + D6b Pitch = 1109 + D6 Pitch = 1175 + D6s Pitch = 1245 + E6b Pitch = 1245 + E6 Pitch = 1319 + F6 Pitch = 1397 + F6s Pitch = 1475 + G6b Pitch = 1475 + G6 Pitch = 1568 + G6s Pitch = 1661 + A6b Pitch = 1661 + A6 Pitch = 1760 + A6s Pitch = 1865 + B6b Pitch = 1865 + B6 Pitch = 1976 + C7 Pitch = 2093 + C7s Pitch = 2218 + D7b Pitch = 2218 + D7 Pitch = 2349 + D7s Pitch = 2489 + E7b Pitch = 2489 + E7 Pitch = 2637 + F7 Pitch = 2794 + F7s Pitch = 2960 + G7b Pitch = 2960 + G7 Pitch = 3136 + G7s Pitch = 3322 + A7b Pitch = 3322 + A7 Pitch = 3520 + A7s Pitch = 3729 + B7b Pitch = 3729 + B7 Pitch = 3951 + C8 Pitch = 4186 + C8s Pitch = 4435 + D8b Pitch = 4435 + D8 Pitch = 4699 + D8s Pitch = 4978 + E8b Pitch = 4978 + E8 Pitch = 5274 + F8 Pitch = 5588 + F8s Pitch = 5920 + G8b Pitch = 5920 + G8 Pitch = 6272 + G8s Pitch = 6645 + A8b Pitch = 6645 + A8 Pitch = 7040 + A8s Pitch = 7459 + B8b Pitch = 7459 + B8 Pitch = 7902 +) + +var ( + allPitches = []Pitch{ + C0, + C0s, + D0, + D0s, + E0, + F0, + F0s, + G0, + G0s, + A0, + A0s, + B0, + C1, + C1s, + D1, + D1s, + E1, + F1, + F1s, + G1, + G1s, + A1, + A1s, + B1, + C2, + C2s, + D2, + D2s, + E2, + F2, + F2s, + G2, + G2s, + A2, + A2s, + B2, + C3, + C3s, + D3, + D3s, + E3, + F3, + F3s, + G3, + G3s, + A3, + A3s, + B3, + C4, + C4s, + D4, + D4s, + E4, + F4, + F4s, + G4, + G4s, + A4, + A4s, + B4, + C5, + C5s, + D5, + D5s, + E5, + F5, + F5s, + G5, + G5s, + A5, + A5s, + B5, + C6, + C6s, + D6, + D6s, + E6, + F6, + F6s, + G6, + G6s, + A6, + A6s, + B6, + C7, + C7s, + D7, + D7s, + E7, + F7, + F7s, + G7, + G7s, + A7, + A7s, + B7, + C8, + C8s, + D8, + D8s, + E8, + F8, + F8s, + G8, + G8s, + A8, + A8s, + B8, + } + + // Reverse lookup for allPitches + noteIndices = map[Pitch]int{ + C0: 0, + C0s: 1, + D0: 2, + D0s: 3, + E0: 4, + F0: 5, + F0s: 6, + G0: 7, + G0s: 8, + A0: 9, + A0s: 10, + B0: 11, + C1: 12, + C1s: 13, + D1: 14, + D1s: 15, + E1: 16, + F1: 17, + F1s: 18, + G1: 19, + G1s: 20, + A1: 21, + A1s: 22, + B1: 23, + C2: 24, + C2s: 25, + D2: 26, + D2s: 27, + E2: 28, + F2: 29, + F2s: 30, + G2: 31, + G2s: 32, + A2: 33, + A2s: 34, + B2: 35, + C3: 36, + C3s: 37, + D3: 38, + D3s: 39, + E3: 40, + F3: 41, + F3s: 42, + G3: 43, + G3s: 44, + A3: 45, + A3s: 46, + B3: 47, + C4: 48, + C4s: 49, + D4: 50, + D4s: 51, + E4: 52, + F4: 53, + F4s: 54, + G4: 55, + G4s: 56, + A4: 57, + A4s: 58, + B4: 59, + C5: 60, + C5s: 61, + D5: 62, + D5s: 63, + E5: 64, + F5: 65, + F5s: 66, + G5: 67, + G5s: 68, + A5: 69, + A5s: 70, + B5: 71, + C6: 72, + C6s: 73, + D6: 74, + D6s: 75, + E6: 76, + F6: 77, + F6s: 78, + G6: 79, + G6s: 80, + A6: 81, + A6s: 82, + B6: 83, + C7: 84, + C7s: 85, + D7: 86, + D7s: 87, + E7: 88, + F7: 89, + F7s: 90, + G7: 91, + G7s: 92, + A7: 93, + A7s: 94, + B7: 95, + C8: 96, + C8s: 97, + D8: 98, + D8s: 99, + E8: 100, + F8: 101, + F8s: 102, + G8: 103, + G8s: 104, + A8: 105, + A8s: 106, + B8: 107, + } +) + +// A Step is an index offset on a pitch +// to raise or lower it to a relative new pitch +type Step int + +// Step values +const ( + HalfStep Step = 1 + WholeStep = 2 + Octave = 12 +) + +// Up raises a pitch s steps +func (p Pitch) Up(s Step) Pitch { + i := noteIndices[p] + if i+int(s) >= len(allPitches) { + return allPitches[len(allPitches)-1] + } + return allPitches[i+int(s)] +} + +// Down lowers a pitch s steps +func (p Pitch) Down(s Step) Pitch { + i := noteIndices[p] + if i-int(s) < 0 { + return allPitches[0] + } + return allPitches[i-int(s)] +} + +// NoteFromIndex is a utility for pitch converters that for some reason have +// integers representing their notes to get a pitch from said integer +func NoteFromIndex(i int) Pitch { + return allPitches[i] +} diff --git a/audio/synth/source.go b/audio/synth/source.go new file mode 100644 index 00000000..1285903a --- /dev/null +++ b/audio/synth/source.go @@ -0,0 +1,49 @@ +package synth + +import ( + "time" + + audio "github.com/oakmound/oak/v3/audio/klang" +) + +// A Source stores necessary information for generating audio and waveform data +type Source struct { + audio.Format + Pitch Pitch + Volume float64 + Seconds float64 +} + +// PlayLength returns the time it will take before audio generated from this +// source will stop. +func (s Source) PlayLength() time.Duration { + return time.Duration(s.Seconds) * 1000 * time.Millisecond +} + +// Phase is shorthand for phase(s.Pitch, i, s.SampleRate). +// Some sources might have custom phase functions in the future, however. +func (s Source) Phase(i int) float64 { + return phase(s.Pitch, i, s.SampleRate) +} + +// Update is shorthand for applying a set of options to a source +func (s Source) Update(opts ...Option) Source { + for _, opt := range opts { + s = opt(s) + } + return s +} + +var ( + // Int16 is a default source for building 16-bit audio + Int16 = Source{ + Format: audio.Format{ + SampleRate: 44100, + Channels: 2, + Bits: 16, + }, + Pitch: A4, + Volume: .25, + Seconds: 1, + } +) diff --git a/audio/synth/waves.go b/audio/synth/waves.go new file mode 100644 index 00000000..df7bc296 --- /dev/null +++ b/audio/synth/waves.go @@ -0,0 +1,143 @@ +// Package synth provides functions and types to support waveform synthesis +package synth + +import ( + "math" + + audio "github.com/oakmound/oak/v3/audio/klang" +) + +// Wave functions take a set of options and return an audio +type Wave func(opts ...Option) (audio.Audio, error) + +// Thanks to https://en.wikibooks.org/wiki/Sound_Synthesis_Theory/Oscillators_and_Wavetables +func phase(freq Pitch, i int, sampleRate uint32) float64 { + return float64(freq) * (float64(i) / float64(sampleRate)) * 2 * math.Pi +} + +func bytesFromInts(is []int16, channels int) []byte { + wave := make([]byte, len(is)*channels*2) + for i := 0; i < len(wave); i += channels * 2 { + wave[i] = byte(is[i/4] % 256) + wave[i+1] = byte(is[i/4] >> 8) + // duplicate the contents across all channels + for c := 1; c < channels; c++ { + wave[i+(2*c)] = wave[i] + wave[i+(2*c)+1] = wave[i+1] + } + } + wave = append(wave, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + return wave +} + +// Sin produces a Sin wave +// __ +// -- -- +// / \ +//--__-- --__-- +func (s Source) Sin(opts ...Option) (audio.Audio, error) { + + s = s.Update(opts...) + + var b []byte + switch s.Bits { + case 16: + s.Volume *= 65535 / 2 + wave := make([]int16, int(s.Seconds*float64(s.SampleRate))) + for i := 0; i < len(wave); i++ { + wave[i] = int16(s.Volume * math.Sin(s.Phase(i))) + } + b = bytesFromInts(wave, int(s.Channels)) + } + return s.Wave(b) +} + +// Pulse acts like Square when given a pulse of 2, when given any lesser +// pulse the time up and down will change so that 1/pulse time the wave will +// be up. +// +// __ __ +// || || +// ____||____||____ +func (s Source) Pulse(pulse float64) Wave { + pulseSwitch := 1 - 2/pulse + return func(opts ...Option) (audio.Audio, error) { + s = s.Update(opts...) + + var b []byte + switch s.Bits { + case 16: + s.Volume *= 65535 / 2 + wave := make([]int16, int(s.Seconds*float64(s.SampleRate))) + for i := range wave { + // alternatively phase % 2pi + if math.Sin(s.Phase(i)) > pulseSwitch { + wave[i] = int16(s.Volume) + } else { + wave[i] = int16(-s.Volume) + } + } + b = bytesFromInts(wave, int(s.Channels)) + } + return s.Wave(b) + } +} + +// Square produces a Square wave +// +// _________ +// | | +// ______| |________ +func (s Source) Square(opts ...Option) (audio.Audio, error) { + return s.Pulse(2)(opts...) +} + +// Saw produces a saw wave +// +// ^ ^ ^ +// / | / | / +// / |/ |/ +func (s Source) Saw(opts ...Option) (audio.Audio, error) { + s = s.Update(opts...) + + var b []byte + switch s.Bits { + case 16: + s.Volume *= 65535 / 2 + wave := make([]int16, int(s.Seconds*float64(s.SampleRate))) + for i := range wave { + wave[i] = int16(s.Volume - (s.Volume / math.Pi * math.Mod(s.Phase(i), 2*math.Pi))) + } + b = bytesFromInts(wave, int(s.Channels)) + } + return s.Wave(b) +} + +// Triangle produces a Triangle wave +// +// ^ ^ +// / \ / \ +// v v v +func (s Source) Triangle(opts ...Option) (audio.Audio, error) { + s = s.Update(opts...) + + var b []byte + switch s.Bits { + case 16: + s.Volume *= 65535 / 2 + wave := make([]int16, int(s.Seconds*float64(s.SampleRate))) + for i := range wave { + p := math.Mod(s.Phase(i), 2*math.Pi) + m := int16(p * (2 * s.Volume / math.Pi)) + if math.Sin(p) > 0 { + wave[i] = int16(-s.Volume) + m + } else { + wave[i] = 3*int16(s.Volume) - m + } + } + b = bytesFromInts(wave, int(s.Channels)) + } + return s.Wave(b) +} + +// Could have pulse triangle diff --git a/audio/wav/testdata/test.wav b/audio/wav/testdata/test.wav new file mode 100644 index 00000000..85eabf65 Binary files /dev/null and b/audio/wav/testdata/test.wav differ diff --git a/audio/wav/wav.go b/audio/wav/wav.go new file mode 100644 index 00000000..07c6fe07 --- /dev/null +++ b/audio/wav/wav.go @@ -0,0 +1,121 @@ +// Package wav provides functionality to handle .wav files and .wav encoded data +package wav + +import ( + "errors" + "io" + + "encoding/binary" + + audio "github.com/oakmound/oak/v3/audio/klang" +) + +// Load loads wav data from the incoming reader as an audio +func Load(r io.Reader) (audio.Audio, error) { + wav, err := Read(r) + if err != nil { + return nil, err + } + return audio.EncodeBytes( + audio.Encoding{ + Data: wav.Data, + Format: audio.Format{ + SampleRate: wav.SampleRate, + Channels: wav.NumChannels, + Bits: wav.BitsPerSample, + }, + }) +} + +// Save will eventually save an audio encoded as a wav to the given writer +func Save(r io.ReadWriter, a audio.Audio) error { + return errors.New("Unsupported Functionality") +} + +// The following is a "fork" of verdverm's go-wav library + +// Data stores the raw information contained in a wav file +type Data struct { + bChunkID [4]byte // B + ChunkSize uint32 // L + bFormat [4]byte // B + + bSubchunk1ID [4]byte // B + Subchunk1Size uint32 // L + + AudioFormat uint16 // L + NumChannels uint16 // L + SampleRate uint32 // L + ByteRate uint32 // L + BlockAlign uint16 // L + BitsPerSample uint16 // L + + bSubchunk2ID [4]byte // B + Subchunk2Size uint32 // L + Data []byte // L +} + +// Read returns raw wav data from an input reader +func Read(r io.Reader) (Data, error) { + wav := Data{} + + err := binary.Read(r, binary.BigEndian, &wav.bChunkID) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.ChunkSize) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.BigEndian, &wav.bFormat) + if err != nil { + return wav, err + } + + err = binary.Read(r, binary.BigEndian, &wav.bSubchunk1ID) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.Subchunk1Size) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.AudioFormat) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.NumChannels) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.SampleRate) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.ByteRate) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.BlockAlign) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.BitsPerSample) + if err != nil { + return wav, err + } + + err = binary.Read(r, binary.BigEndian, &wav.bSubchunk2ID) + if err != nil { + return wav, err + } + err = binary.Read(r, binary.LittleEndian, &wav.Subchunk2Size) + if err != nil { + return wav, err + } + + wav.Data = make([]byte, wav.Subchunk2Size) + err = binary.Read(r, binary.LittleEndian, &wav.Data) + + return wav, err +} diff --git a/collision/attachSpace_test.go b/collision/attachSpace_test.go index 1323ad10..45e9880a 100644 --- a/collision/attachSpace_test.go +++ b/collision/attachSpace_test.go @@ -18,7 +18,7 @@ func (as *aspace) Init() event.CID { func TestAttachSpace(t *testing.T) { Clear() - go event.ResolvePending() + go event.ResolveChanges() go func() { for { <-time.After(5 * time.Millisecond) diff --git a/collision/onCollision.go b/collision/onCollision.go index d6393c78..784aa90c 100644 --- a/collision/onCollision.go +++ b/collision/onCollision.go @@ -11,6 +11,7 @@ import ( type Phase struct { OnCollisionS *Space tree *Tree + bus event.Handler // If allocating maps becomes an issue // we can have two constant maps that we // switch between on alternating frames @@ -30,15 +31,22 @@ type collisionPhase interface { // entities begin to collide or stop colliding with the space. // If tree is nil, it uses DefTree func PhaseCollision(s *Space, tree *Tree) error { - en := s.CID.E() + return PhaseCollisionWithBus(s, tree, event.DefaultBus, event.DefaultCallerMap) +} + +// PhaseCollisionWithBus allows for a non-default bus and non-default entity mapping +// in a phase collision binding. +func PhaseCollisionWithBus(s *Space, tree *Tree, bus event.Handler, entities *event.CallerMap) error { + en := entities.GetEntity(s.CID) if cp, ok := en.(collisionPhase); ok { oc := cp.getCollisionPhase() oc.OnCollisionS = s oc.tree = tree + oc.bus = bus if oc.tree == nil { oc.tree = DefaultTree } - s.CID.Bind(event.Enter, phaseCollisionEnter) + bus.Bind(event.Enter, s.CID, phaseCollisionEnter(entities)) return nil } return errors.New("This space's entity does not implement collisionPhase") @@ -51,31 +59,33 @@ const ( Stop = "CollisionStop" ) -func phaseCollisionEnter(id event.CID, nothing interface{}) int { - e := id.E().(collisionPhase) - oc := e.getCollisionPhase() +func phaseCollisionEnter(entities *event.CallerMap) func(id event.CID, nothing interface{}) int { + return func(id event.CID, nothing interface{}) int { + e := entities.GetEntity(id).(collisionPhase) + oc := e.getCollisionPhase() - // check hits - hits := oc.tree.Hits(oc.OnCollisionS) - newTouching := map[Label]bool{} + // check hits + hits := oc.tree.Hits(oc.OnCollisionS) + newTouching := map[Label]bool{} - // if any are new, trigger on collision - for _, h := range hits { - l := h.Label - if _, ok := oc.Touching[l]; !ok { - event.CID(id).Trigger(Start, l) + // if any are new, trigger on collision + for _, h := range hits { + l := h.Label + if _, ok := oc.Touching[l]; !ok { + id.TriggerBus(Start, l, oc.bus) + } + newTouching[l] = true } - newTouching[l] = true - } - // if we lost any, trigger off collision - for l := range oc.Touching { - if _, ok := newTouching[l]; !ok { - event.CID(id).Trigger(Stop, l) + // if we lost any, trigger off collision + for l := range oc.Touching { + if _, ok := newTouching[l]; !ok { + id.TriggerBus(Stop, l, oc.bus) + } } - } - oc.Touching = newTouching + oc.Touching = newTouching - return 0 + return 0 + } } diff --git a/collision/onCollision_test.go b/collision/onCollision_test.go index f0d7397a..82777fbc 100644 --- a/collision/onCollision_test.go +++ b/collision/onCollision_test.go @@ -9,45 +9,51 @@ import ( type cphase struct { Phase + callers *event.CallerMap } func (cp *cphase) Init() event.CID { - return event.NextID(cp) + return cp.callers.NextID(cp) } func TestCollisionPhase(t *testing.T) { - go event.ResolvePending() + callers := event.NewCallerMap() + bus := event.NewBus(callers) + go bus.ResolveChanges() go func() { for { <-time.After(5 * time.Millisecond) - <-event.TriggerBack(event.Enter, nil) + <-bus.TriggerBack(event.Enter, nil) } }() - cp := cphase{} + cp := cphase{ + callers: callers, + } cid := cp.Init() s := NewSpace(10, 10, 10, 10, cid) - err := PhaseCollision(s, nil) + tree := NewTree() + err := PhaseCollisionWithBus(s, tree, bus, callers) if err != nil { t.Fatalf("phase collision failed: %v", err) } var active bool - cid.Bind("CollisionStart", func(event.CID, interface{}) int { + bus.Bind("CollisionStart", cid, func(event.CID, interface{}) int { active = true return 0 }) - cid.Bind("CollisionStop", func(event.CID, interface{}) int { + bus.Bind("CollisionStop", cid, func(event.CID, interface{}) int { active = false return 0 }) s2 := NewLabeledSpace(15, 15, 10, 10, 5) - Add(s2) + tree.Add(s2) time.Sleep(200 * time.Millisecond) if !active { t.Fatalf("collision should be active") } - Remove(s2) + tree.Remove(s2) time.Sleep(200 * time.Millisecond) if active { t.Fatalf("collision should be inactive") @@ -58,9 +64,4 @@ func TestCollisionPhase(t *testing.T) { if err == nil { t.Fatalf("phase collision should have failed") } - - err = PhaseCollision(s, DefaultTree) - if err != nil { - t.Fatalf("phase collision failed: %v", err) - } } diff --git a/collision/onHit.go b/collision/onHit.go index 82c86014..e3634752 100644 --- a/collision/onHit.go +++ b/collision/onHit.go @@ -5,19 +5,24 @@ type OnHit func(s, s2 *Space) // CallOnHits will send a signal to the passed in channel // when it has completed all collision functions in the hitmap. -// It acts on DefTree. Todo: Change that -func CallOnHits(s *Space, m map[Label]OnHit, doneCh chan bool) { +func CallOnHits(s *Space, onHits map[Label]OnHit, doneCh chan bool) { + DefaultTree.CallOnHits(s, onHits, doneCh) +} + +// CallOnHits will send a signal to the passed in channel +// when it has completed all collision functions in the hitmap. +func (t *Tree) CallOnHits(s *Space, onHits map[Label]OnHit, doneCh chan bool) { progCh := make(chan bool) - hits := Hits(s) + hits := t.Hits(s) for _, s2 := range hits { - go func(s, s2 *Space, m map[Label]OnHit, progCh chan bool) { - if fn, ok := m[s2.Label]; ok { + go func(s, s2 *Space, onHits map[Label]OnHit, progCh chan bool) { + if fn, ok := onHits[s2.Label]; ok { fn(s, s2) progCh <- true return } progCh <- false - }(s, s2, m, progCh) + }(s, s2, onHits, progCh) } // This waits to send our signal that we've // finished until we've counted signals for diff --git a/collision/reactiveSpace.go b/collision/reactiveSpace.go index 7ab8af5c..8a47c393 100644 --- a/collision/reactiveSpace.go +++ b/collision/reactiveSpace.go @@ -1,47 +1,52 @@ package collision +import "sync" + // ReactiveSpace is a space that keeps track of a map of collision events type ReactiveSpace struct { *Space - onHits map[Label]OnHit -} + Tree *Tree -// NewEmptyReactiveSpace returns a reactive space with no onHit mapping -func NewEmptyReactiveSpace(s *Space) *ReactiveSpace { - return &ReactiveSpace{ - Space: s, - onHits: make(map[Label]OnHit), - } + onHitsLock sync.Mutex + onHits map[Label]OnHit } -// NewReactiveSpace creates a reactive space +// NewReactiveSpace creates a reactive space on the default collision tree func NewReactiveSpace(s *Space, onHits map[Label]OnHit) *ReactiveSpace { return &ReactiveSpace{ Space: s, + Tree: DefaultTree, onHits: onHits, } } // CallOnHits calls CallOnHits on the underlying space of a reactive space // with the reactive spaces' map of collision events, and returns the channel -// it will send the done signal from. +// it will send the done signal from. It is not safe to call concurrently with +// add / remove / clear. func (rs *ReactiveSpace) CallOnHits() chan bool { doneCh := make(chan bool) - go CallOnHits(rs.Space, rs.onHits, doneCh) + go rs.Tree.CallOnHits(rs.Space, rs.onHits, doneCh) return doneCh } // Add adds a mapping to a reactive spaces' onhit map func (rs *ReactiveSpace) Add(i Label, oh OnHit) { + rs.onHitsLock.Lock() + defer rs.onHitsLock.Unlock() rs.onHits[i] = oh } // Remove removes a mapping from a reactive spaces' onhit map func (rs *ReactiveSpace) Remove(i Label) { + rs.onHitsLock.Lock() + defer rs.onHitsLock.Unlock() delete(rs.onHits, i) } // Clear resets a reactive space's onhit map func (rs *ReactiveSpace) Clear() { + rs.onHitsLock.Lock() + defer rs.onHitsLock.Unlock() rs.onHits = make(map[Label]OnHit) } diff --git a/collision/reactiveSpace_test.go b/collision/reactiveSpace_test.go index deb663e2..0764221d 100644 --- a/collision/reactiveSpace_test.go +++ b/collision/reactiveSpace_test.go @@ -7,7 +7,7 @@ import ( func TestReactiveSpace(t *testing.T) { Clear() var triggered bool - rs1 := NewEmptyReactiveSpace(NewUnassignedSpace(0, 0, 10, 10)) + rs1 := NewReactiveSpace(NewUnassignedSpace(0, 0, 10, 10), map[Label]OnHit{}) if rs1 == nil { t.Fatalf("reactive space was nil after creation") } diff --git a/collision/rtree.go b/collision/rtree.go index abbb5dec..836ade53 100644 --- a/collision/rtree.go +++ b/collision/rtree.go @@ -192,7 +192,6 @@ func (n *node) split(minGroupSize int) (left, right *node) { entries: []entry{rightSeed}, } - // TODO if rightSeed.child != nil { rightSeed.child.parent = right } diff --git a/collision/space.go b/collision/space.go index 831d7f40..f8d7d543 100644 --- a/collision/space.go +++ b/collision/space.go @@ -1,9 +1,6 @@ package collision import ( - "fmt" - "strconv" - "github.com/oakmound/oak/v3/alg/floatgeom" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/physics" @@ -12,8 +9,8 @@ import ( // ID Types constant const ( NONE = iota - CID - PID + IDTypeCID + IDTypePID ) // A Space is a rectangle @@ -194,14 +191,6 @@ func (s *Space) SubtractRect(x2, y2, w2, h2 float64) []*Space { return spaces } -func (s *Space) String() string { - return strconv.FormatFloat(s.X(), 'f', 2, 32) + "," + - strconv.FormatFloat(s.Y(), 'f', 2, 32) + "," + - strconv.FormatFloat(s.GetW(), 'f', 2, 32) + "," + - strconv.FormatFloat(s.GetH(), 'f', 2, 32) + "::" + - strconv.Itoa(int(s.CID)) + "::" + fmt.Sprintf("%p", s) -} - // NewUnassignedSpace returns a space that just has a rectangle func NewUnassignedSpace(x, y, w, h float64) *Space { return NewLabeledSpace(x, y, w, h, NilLabel) @@ -229,9 +218,7 @@ func NewFullSpace(x, y, w, h float64, l Label, cID event.CID) *Space { rect, l, cID, - CID, // todo: This is hard to read as distinct from cID - // todo: a way to generate non-CID typed spaces that isn't - // package specific (see render/particle) + IDTypeCID, } } @@ -246,7 +233,7 @@ func NewRectSpace(rect floatgeom.Rect3, l Label, cID event.CID) *Space { rect, l, cID, - CID, + IDTypeCID, } } @@ -267,3 +254,8 @@ func NewRect(x, y, w, h float64) floatgeom.Rect3 { } return floatgeom.NewRect3WH(x, y, 0, w, h, 1) } + +func (s *Space) SetZLayer(z float64) { + s.Location.Min[2] = z + s.Location.Max[2] = z +} diff --git a/collision/space_test.go b/collision/space_test.go index 9a43ae61..8c41e398 100644 --- a/collision/space_test.go +++ b/collision/space_test.go @@ -11,9 +11,6 @@ import ( func TestSpaceFuncs(t *testing.T) { Clear() s := NewUnassignedSpace(10, 10, 10, 10) - if s.String() == "" { - t.Fatalf("space.String() should not be an empty string") - } if s.W() != 10.0 { t.Fatalf("expected 10 width, got %v", s.W()) } diff --git a/config.go b/config.go index 79d7a7ec..1d234513 100644 --- a/config.go +++ b/config.go @@ -30,6 +30,7 @@ type Config struct { TopMost bool `json:"topmost"` Borderless bool `json:"borderless"` Fullscreen bool `json:"fullscreen"` + SkipRNGSeed bool `json:"skip_rng_seed"` } // A Duration is a wrapper around time.Duration that allows for easier json formatting. @@ -267,5 +268,6 @@ func (c Config) overwriteFrom(c2 Config) Config { c.TopMost = c2.TopMost c.Borderless = c2.Borderless c.Fullscreen = c2.Fullscreen + c.SkipRNGSeed = c2.SkipRNGSeed return c } diff --git a/config_test.go b/config_test.go index aacf4b57..a683d16b 100644 --- a/config_test.go +++ b/config_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestDefaultConfigFileMatchesEmptyConfig(t *testing.T) { @@ -35,6 +36,29 @@ func TestDefaultConfigFileMatchesEmptyConfig(t *testing.T) { } } +func TestConfig_overwriteFrom(t *testing.T) { + // coverage test + c2 := Config{ + Debug: Debug{ + Filter: "filter", + }, + Screen: Screen{ + X: 1, + Y: 1, + TargetWidth: 1, + TargetHeight: 1, + }, + Font: Font{ + File: "lusixr.ttf", + }, + BatchLoadOptions: BatchLoadOptions{ + MaxImageFileSize: 10000, + }, + } + c1 := Config{} + c1.overwriteFrom(c2) +} + func TestFileConfigBadFile(t *testing.T) { _, err := NewConfig(FileConfig("badpath")) if err == nil { @@ -53,3 +77,59 @@ func TestReaderConfigBadJSON(t *testing.T) { // This error is an stdlib error, not ours, so we don't care // about its type } + +func TestDuration_HappyPath(t *testing.T) { + d := Duration(time.Second) + marshalled, err := d.MarshalJSON() + if err != nil { + t.Fatalf("marshal duration failed: %v", err) + } + d2 := new(Duration) + err = d2.UnmarshalJSON(marshalled) + if err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + marshalled2, err := d2.MarshalJSON() + if err != nil { + t.Fatalf("marshal duration 2 failed: %v", err) + } + if !bytes.Equal(marshalled, marshalled2) { + t.Fatalf("marshals not equal: %v vs %v", string(marshalled), string(marshalled2)) + } +} + +func TestDuration_UnmarshalJSON_Float(t *testing.T) { + f := []byte("10.0") + d2 := new(Duration) + err := d2.UnmarshalJSON(f) + if err != nil { + t.Fatalf("unmarshal failed: %v", err) + } +} + +func TestDuration_UnmarshalJSON_Boolean(t *testing.T) { + f := []byte("false") + d2 := new(Duration) + err := d2.UnmarshalJSON(f) + if err == nil { + t.Fatalf("expected failure in unmarshal") + } +} + +func TestDuration_UnmarshalJSON_BadString(t *testing.T) { + f := []byte("\"10mmmm\"") + d2 := new(Duration) + err := d2.UnmarshalJSON(f) + if err == nil { + t.Fatalf("expected failure in unmarshal") + } +} + +func TestDuration_UnmarshalJSON_BadJSON(t *testing.T) { + f := []byte("\"1mm") + d2 := new(Duration) + err := d2.UnmarshalJSON(f) + if err == nil { + t.Fatalf("expected failure in unmarshal") + } +} diff --git a/cover.sh b/cover.sh index 867d67d7..009f2f07 100755 --- a/cover.sh +++ b/cover.sh @@ -34,6 +34,11 @@ if [ -f profile.out ]; then cat profile.out >> coverage.txt rm profile.out fi +go test -coverprofile=profile.out -covermode=atomic ./debugstream +if [ -f profile.out ]; then + cat profile.out >> coverage.txt + rm profile.out +fi go test -coverprofile=profile.out -covermode=atomic ./dlog if [ -f profile.out ]; then cat profile.out >> coverage.txt @@ -54,6 +59,11 @@ if [ -f profile.out ]; then cat profile.out >> coverage.txt rm profile.out fi +go test -coverprofile=profile.out -covermode=atomic --tags=nooswindow . +if [ -f profile.out ]; then + cat profile.out >> coverage.txt + rm profile.out +fi go test -coverprofile=profile.out -covermode=atomic ./oakerr if [ -f profile.out ]; then cat profile.out >> coverage.txt diff --git a/debugConsole.go b/debugConsole.go deleted file mode 100644 index 337c7c92..00000000 --- a/debugConsole.go +++ /dev/null @@ -1,240 +0,0 @@ -package oak - -import ( - "bufio" - "fmt" - "io" - "reflect" - "sort" - "strconv" - "strings" - - "github.com/oakmound/oak/v3/oakerr" - - "github.com/oakmound/oak/v3/collision" - "github.com/oakmound/oak/v3/dlog" - "github.com/oakmound/oak/v3/event" - "github.com/oakmound/oak/v3/mouse" - "github.com/oakmound/oak/v3/render" - "github.com/oakmound/oak/v3/render/mod" -) - -// AddCommand adds a console command to call fn when -// ' ' is input to the console. fn will be called -// with args split on whitespace. -func (c *Controller) AddCommand(s string, fn func([]string)) error { - return c.addCommand(s, fn, false) -} - -// ForceAddCommand adds or overwrites a console command to call fn when -// ' ' is input to the console. fn will be called -// with args split on whitespace. -func (c *Controller) ForceAddCommand(s string, fn func([]string)) { - c.addCommand(s, fn, true) -} - -func (c *Controller) addCommand(s string, fn func([]string), force bool) error { - if _, ok := c.commands[s]; ok { - if !force { - return oakerr.ExistingElement{ - InputName: "s", - InputType: "string", - Overwritten: false, - } - } - } - c.commands[s] = fn - return nil -} - -// ClearCommand clears an existing debug command by key: -func (c *Controller) ClearCommand(s string) { - delete(c.commands, s) -} - -// ResetCommands will throw out all existing debug commands from the -// debug console. -func (c *Controller) ResetCommands() { - c.commands = map[string]func([]string){} -} - -// GetDebugKeys returns the current debug console commands as a string array -func (c *Controller) GetDebugKeys() []string { - dkeys := make([]string, len(c.commands)) - i := 0 - for k := range c.commands { - dkeys[i] = k - i++ - } - return dkeys -} - -func (c *Controller) debugConsole(input io.Reader) { - scanner := bufio.NewScanner(input) - - // built in commands - if c.config.LoadBuiltinCommands { - dlog.ErrorCheck(c.AddCommand("fade", c.fadeCommands)) - dlog.ErrorCheck(c.AddCommand("skip", c.skipCommands)) - dlog.ErrorCheck(c.AddCommand("print", c.printCommands)) - dlog.ErrorCheck(c.AddCommand("mouse", c.mouseCommands)) - dlog.ErrorCheck(c.AddCommand("move", c.moveWindow)) - dlog.ErrorCheck(c.AddCommand("fullscreen", c.fullScreen)) - dlog.ErrorCheck(c.AddCommand("help", c.printDebugCommands)) - dlog.ErrorCheck(c.AddCommand("quit", func([]string) { c.Quit() })) - } - - for { - for scanner.Scan() { - tokenString := strings.Fields(scanner.Text()) - if len(tokenString) == 0 { - continue - } - if fn, ok := c.commands[tokenString[0]]; ok { - fn(tokenString[1:]) - } else { - fmt.Println("Unknown command", tokenString[0]) - } - } - } -} - -func parseTokenAsInt(tokenString []string, arrIndex int, defaultVal int) int { - if len(tokenString) > arrIndex { - tmp, err := strconv.Atoi(tokenString[arrIndex]) - if err == nil { - return tmp - } - } - return defaultVal -} - -func (c *Controller) mouseDetails(nothing event.CID, mevent interface{}) int { - me := mevent.(mouse.Event) - x := int(me.X()) + c.viewPos[0] - y := int(me.Y()) + c.viewPos[1] - loc := collision.NewUnassignedSpace(float64(x), float64(y), 16, 16) - results := collision.Hits(loc) - fmt.Println("Mouse at:", x, y, "rel:", me.X(), me.Y()) - if len(results) == 0 { - results = mouse.Hits(loc) - } - if len(results) > 0 { - i := int(results[0].CID) - if i > 0 && event.HasEntity(event.CID(i)) { - e := event.GetEntity(event.CID(i)) - fmt.Printf("%+v\n", e) - } else { - fmt.Println("No entity ", i) - } - } - - return 0 -} - -func (c *Controller) fadeCommands(tokenString []string) { - if len(tokenString) > 0 { - fmt.Println("Input must start with the name of the renderable to fade") - return - } - toFade, ok := render.GetDebugRenderable(tokenString[0]) - if ok { - fadeVal := parseTokenAsInt(tokenString, 1, 255) - toFade.(render.Modifiable).Filter(mod.Fade(fadeVal)) - } else { - fmt.Println("Could not fade input") - } -} - -func (c *Controller) skipCommands(tokenString []string) { - if len(tokenString) != 1 { - fmt.Println("Input must be a single string from the following (\"scene\"). ") - return - } - switch tokenString[0] { - case "scene": - c.NextScene() - default: - fmt.Println("Bad Skip Input") - } -} - -func (c *Controller) printCommands(tokenString []string) { - if len(tokenString) != 1 { - fmt.Println("Input must be a single number that corresponds to an entity.") - return - } - i, err := strconv.Atoi(tokenString[0]) - if err != nil { - fmt.Println("Unable to parse", tokenString[0]) - return - } - if i > 0 && event.HasEntity(event.CID(i)) { - e := event.GetEntity(event.CID(i)) - fmt.Println(reflect.TypeOf(e), e) - } else { - fmt.Println("No entity ", i) - } - -} - -func (c *Controller) mouseCommands(tokenString []string) { - if len(tokenString) != 1 { - fmt.Println("Input must be a single string from the following (\"details\") ") - return - } - switch tokenString[0] { - case "details": - event.GlobalBind("MouseRelease", c.mouseDetails) - default: - fmt.Println("Bad Mouse Input") - } -} - -func (c *Controller) printDebugCommands(tokenString []string) { - dbgKeys := c.GetDebugKeys() - sort.Strings(dbgKeys) - fmt.Printf("Commands: %s\n", strings.Join(dbgKeys, ", ")) -} - -func (c *Controller) moveWindow(in []string) { - if len(in) < 4 { - dlog.Error("Insufficient integer arguments for moving window") - return - } - ints := make([]int, 4) - var err error - for i := range ints { - ints[i], err = strconv.Atoi(in[i]) - if err != nil { - dlog.Error(err) - return - } - } - //err = c.MoveWindow(ints[0], ints[1], ints[2], ints[3]) - dlog.ErrorCheck(err) -} - -func (c *Controller) fullScreen(sub []string) { - // on := true - // if len(sub) > 0 { - // if sub[0] == "off" { - // on = false - // } - // } - //err := c.SetFullScreen(on) - //dlog.ErrorCheck(err) -} - -// RunCommand runs a command added with AddCommand. -// It's intended use is making it easier to -// alias commands/subcommands. -// It returns an error if the command doesn't exist. -func (c *Controller) RunCommand(cmd string, args ...string) error { - fn, ok := c.commands[cmd] - if !ok { - return fmt.Errorf("Unknown command %s", cmd) - } - fn(args) - return nil -} diff --git a/debugConsole_test.go b/debugConsole_test.go deleted file mode 100644 index 8db7f7b4..00000000 --- a/debugConsole_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package oak - -import ( - "bytes" - "testing" - - "github.com/oakmound/oak/v3/collision" - "github.com/oakmound/oak/v3/event" - "github.com/oakmound/oak/v3/mouse" - "github.com/oakmound/oak/v3/render" -) - -type ent struct{} - -func (e ent) Init() event.CID { - return 0 -} - -func TestDebugConsole(t *testing.T) { - c1 := NewController() - c1.config.LoadBuiltinCommands = true - triggered := false - err := c1.AddCommand("test", func([]string) { - triggered = true - }) - if err != nil { - t.Fatalf("failed to add test command") - } - - render.UpdateDebugMap("r", render.EmptyRenderable()) - - event.NextID(ent{}) - - r := bytes.NewBufferString( - "test\n" + - "nothing\n" + - "fade nothing\n" + - "fade nothing 100\n" + - "fade r\n" + - "skip nothing\n" + - "print nothing\n" + - "print 2\n" + - "print 1\n" + - "mouse nothing\n" + - "mouse details\n" + - "garbage input\n" + - "\n" + - "skip scene\n") - go c1.debugConsole(r) - sleep() - sleep() - if !triggered { - t.Fatalf("debug console did not trigger test command") - } - <-c1.skipSceneCh -} - -func TestMouseDetails(t *testing.T) { - c1 := NewController() - - c1.mouseDetails(0, mouse.NewZeroEvent(0, 0)) - s := collision.NewUnassignedSpace(-1, -1, 2, 2) - collision.Add(s) - c1.mouseDetails(0, mouse.NewZeroEvent(0, 0)) - collision.Remove(s) - - // This should spew this nothing entity, but it doesn't. - id := event.NextID(ent{}) - s = collision.NewSpace(-1, -1, 2, 2, id) - c1.mouseDetails(0, mouse.NewZeroEvent(0, 0)) - collision.Remove(s) -} diff --git a/debugstream/commands.go b/debugstream/commands.go new file mode 100644 index 00000000..483a0644 --- /dev/null +++ b/debugstream/commands.go @@ -0,0 +1,358 @@ +package debugstream + +import ( + "bufio" + "context" + "fmt" + "io" + "sort" + "strconv" + "strings" + "sync" + + "github.com/oakmound/oak/v3/oakerr" +) + +// ScopedCommands for the debug stream commands. +// Contains a set of scopes that align with oak.Controller. +// Currently can only be attached to a single stream +type ScopedCommands struct { + sync.Mutex + attachOnce sync.Once + assumedScope int32 + scopes []int32 + commands map[int32]map[string]Command +} + +// Command is a local format for performing these debug stream things. +type Command struct { + Name string + ScopeID int32 + Operation func([]string) string // the actual operation to execute + Usage string // usage string, print when 'help' is called + Force bool // replace any existing command by this name +} + +// NewScopedCommands creates set of standard help functions. +func NewScopedCommands() *ScopedCommands { + sc := &ScopedCommands{commands: map[int32]map[string]Command{}} + sc.AddCommand(Command{ + Name: "help", + Operation: sc.printHelp, + }) + sc.AddCommand(Command{ + Name: "scope", + Usage: explainAssumeScope, + Operation: sc.assumeScope, + }) + sc.AddCommand(Command{ + Name: "fade", + Usage: explainFade, + Operation: fadeCommands, + }) + return sc +} + +// AttachToStream and start executing the registered commands on input to said stream. +// Currently a given set of scoped commands may be attached once and only once. It will stop +// parsing commands when the provided context is done. +func (sc *ScopedCommands) AttachToStream(ctx context.Context, input io.Reader, out io.Writer) { + sc.attachOnce.Do( + func() { + textIn := make(chan string) + go func(textBuffer chan string, in io.Reader) { + scanner := bufio.NewScanner(in) + for { + + for scanner.Scan() { + textBuffer <- scanner.Text() + } + } + }(textIn, input) + + go func() { + + for { + select { + case <-ctx.Done(): + out.Write([]byte("stopping debugstream")) + return + + case scanText := <-textIn: + + // TODO: accept interrupts + + tokenString := strings.Fields(scanText) + if len(tokenString) == 0 { + continue + } + // Attempt to parse the first arg as a scope + scopeID, err := strToInt32(tokenString[0]) + + tokenIDX := 0 + // if there was a scope specified then increment what we care about + if err == nil { + _, ok := sc.commands[scopeID] + if !ok { + out.Write([]byte(fmt.Sprintf("unknown scopeID %d\n", scopeID))) + continue + } + if len(tokenString) == 1 { + out.Write([]byte(fmt.Sprintf("only provided scopeID %d without command\n", scopeID))) + continue + } + + tokenIDX++ + // see if specified + potentialOp := strings.ToLower(tokenString[tokenIDX]) + if cmd, ok := sc.commands[scopeID][potentialOp]; ok { + commandOut := cmd.Operation(tokenString[tokenIDX+1:]) + out.Write([]byte(commandOut)) + continue + } + } + potentialOp := strings.ToLower(tokenString[0]) + // assumedscope + if cmd, ok := sc.commands[sc.assumedScope][potentialOp]; ok { + commandOut := cmd.Operation(tokenString[1:]) + out.Write([]byte(commandOut)) + continue + } + + // fallback to scope 0 + if cmd, ok := sc.commands[0][potentialOp]; ok { + commandOut := cmd.Operation(tokenString[1:]) + out.Write([]byte(commandOut)) + continue + } + + out.Write([]byte(fmt.Sprintf("Unknown command '%s' for scopeID %d see correct usage via help or help %d\n", tokenString[tokenIDX], scopeID, scopeID))) + suggestions := sc.suggestForCandidate(4, tokenString[tokenIDX]) + if len(suggestions) > 0 { + out.Write([]byte("Did you mean one of the following?\n")) + for _, s := range suggestions { + out.Write([]byte(indent + s + "\n")) + } + } + + } + } + }() + }) +} + +// AddCommand adds a console command to call fn when +// ' ' is input to the console. fn will be called +// with args split on whitespace. +func (sc *ScopedCommands) AddCommand(c Command) error { + + s := strings.ToLower(c.Name) + scopeID := c.ScopeID + + sc.Lock() + defer sc.Unlock() + + if _, ok := sc.commands[scopeID]; !ok { + + sc.commands[scopeID] = map[string]Command{} + sc.scopes = append(sc.scopes, scopeID) + } + + if _, ok := sc.commands[scopeID][s]; ok { + if !c.Force { + return oakerr.ExistingElement{ + InputName: c.Name, + InputType: "string", + Overwritten: false, + } + } + } + sc.commands[scopeID][s] = c + return nil +} + +// ClearCommand clears an existing debug command for scope with key: +func (sc *ScopedCommands) ClearCommand(scopeID int32, s string) { + _, ok := sc.commands[scopeID] + if !ok { + return + } + delete(sc.commands[scopeID], s) +} + +// ResetCommands will throw out all existing debug commands from the +// debug console. +func (sc *ScopedCommands) ResetCommands() { + sc.commands = map[int32]map[string]Command{} +} + +// ResetCommandsForScope will throw out all existing debug commands from the +// debug console for hte given scope. +func (sc *ScopedCommands) ResetCommandsForScope(scope int32) { + sc.commands[scope] = map[string]Command{} +} + +// RemoveScope from the command set. +// Usually done on the close of a scope. +func (sc *ScopedCommands) RemoveScope(scope int32) { + delete(sc.commands, scope) + for i := 0; i < len(sc.scopes); i++ { + if sc.scopes[i] == scope { + sc.scopes = append(sc.scopes[:i], sc.scopes[i+1:]...) + return + } + } +} + +// GetDebugKeys returns the current debug console commands as a string array +func (sc *ScopedCommands) CommandsInScope(scope int32, showUsage bool) []string { + + cmds, ok := sc.commands[scope] + if !ok { + return []string{} + } + + dkeys := make([]string, len(cmds)) + i := 0 + for k, command := range cmds { + dkeys[i] = k + "\n" + if showUsage && command.Usage != "" { + dkeys[i] = fmt.Sprintf("%s: %s\n", k, command.Usage) + } + i++ + } + sort.Strings(dkeys) + return dkeys +} + +// printHelp descriptions. +// Either for everything, a given scopeID, a given command, or a scopeID with a command. +func (sc *ScopedCommands) printHelp(tokenString []string) (out string) { + scopeID := sc.assumedScope + commandStr := "" + var err error + tknIndex := 0 + if len(tokenString) > 0 { + + // Check for a scope + scopeID, err = strToInt32(tokenString[0]) + if err == nil { + tknIndex++ + } + // check for a command of interest + if len(tokenString) > tknIndex { + commandStr = tokenString[tknIndex] + } + } + + // error out if the scopeID is invalid for one reason or another + if _, ok := sc.commands[scopeID]; !ok { + if scopeID == sc.assumedScope { + out += fmt.Sprintf("current scope %v is not usable please use 'scope 0' or 'scope\n", scopeID) + } else { + out += fmt.Sprintf("inactive scope %d see correct usage by using help without the scope\n", scopeID) + } + return + } + + if scopeID == sc.assumedScope { + out += "help to see commands linked to a given window\n" + + fmt.Sprintf("Active Scopes: %v\n", sc.scopes) + } + + out += fmt.Sprintf("Current Assumed Scope: %v\n", sc.assumedScope) // TODO: if in a verbose mode present usage. + + // give a general overview if a specific command is not specified + if commandStr == "" { + out += fmt.Sprintf("General Commands:\n%s%s\n", indent, strings.Join(sc.CommandsInScope(0, true), indent)) + if scopeID != 0 { + out += fmt.Sprintf("Current Window Commands:\n%s%s\n", indent, strings.Join(sc.CommandsInScope(scopeID, true), indent)) + } + return + } + + out += fmt.Sprintf("Registered Instances of %s\n", commandStr) + // return just the usage for the given command + for scope, cmdSet := range sc.commands { + if c, ok := cmdSet[commandStr]; ok { + out += fmt.Sprintf("%sscope%v %s: %s\n", indent, scopeID, commandStr, c.Usage) + } else { + if scope == scopeID { + out += fmt.Sprintf("%sWarning scope '%v' did not have the specified command %q\n", indent, scopeID, commandStr) + } + } + + } + + return +} + +const indent = " " +const explainAssumeScope = "provide a scopeID to use commands without a scopeID prepended" + +// assumeScope of the given windowID if possible +// This allows for easier usage of windows when multiple windows exist. +func (sc *ScopedCommands) assumeScope(tokenString []string) (out string) { + if len(tokenString) == 0 { + out += "assume scope requires a scopeID\n" + + fmt.Sprintf("Active Scopes: %v\n", sc.scopes) + return + } + + scopeID, err := strToInt32(tokenString[0]) + if err != nil { + out += "assume scope expects a valid int32 scope\n" + + fmt.Sprintf("you provided %q which errored with %v\n", tokenString[0], err) + return + } + if _, ok := sc.commands[scopeID]; !ok { + out += fmt.Sprintf("inactive scope %d\n", scopeID) + return + } + sc.assumedScope = scopeID + out += fmt.Sprintf("assumed scope %v\n", scopeID) + return +} + +func (sc *ScopedCommands) suggestForCandidate(maxSuggestions int, candidate string) (suggestions []string) { + + possibilities := []candidateStore{} + scopes := []int32{sc.assumedScope} + if sc.assumedScope != 0 { + scopes = append(scopes, 0) + } + for _, s := range scopes { + for c := range sc.commands[s] { + _, val := jaroDecreased(candidate, c) + if val > suggestionCuttOff { + possibilities = append(possibilities, candidateStore{c, val}) + } + } + } + + sort.Slice(possibilities, func(i, j int) bool { + return possibilities[i].value > possibilities[j].value + }) + + maxS := maxSuggestions + if len(possibilities) <= maxS { + maxS = len(possibilities) + } + for i := 0; i < maxS; i++ { + suggestions = append(suggestions, possibilities[i].name) + } + + return suggestions +} + +const suggestionCuttOff = 0.4 + +type candidateStore struct { + name string + value float64 +} + +func strToInt32(potentialInt string) (int32, error) { + i64, err := strconv.ParseInt(potentialInt, 10, 32) + return int32(i64), err +} diff --git a/debugstream/commands_test.go b/debugstream/commands_test.go new file mode 100644 index 00000000..fd5200b3 --- /dev/null +++ b/debugstream/commands_test.go @@ -0,0 +1,124 @@ +package debugstream + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +func TestScopedCommands(t *testing.T) { + sc := NewScopedCommands() + if len(sc.commands) != 1 { + t.Fatalf("scoped commands failed to create with one scope: had %v", len(sc.commands)) + } + if len(sc.commands[0]) != 3 { + t.Fatalf("scoped commands failed to create with three commands: had %v", len(sc.commands[0])) + } +} + +func TestScopedCommands_AssumeScope(t *testing.T) { + sc := NewScopedCommands() + + in := bytes.NewBufferString("scope\nscope zero\nscope 2\nscope 0\n0 scope 0\n2 scope 0\n0") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(50 * time.Millisecond) + + expected := `assume scope requires a scopeID +Active Scopes: [0] +assume scope expects a valid int32 scope +you provided "zero" which errored with strconv.ParseInt: parsing "zero": invalid syntax +inactive scope 2 +assumed scope 0 +assumed scope 0 +unknown scopeID 2 +only provided scopeID 0 without command +` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } +} + +func TestScopedCommands_Help(t *testing.T) { + sc := NewScopedCommands() + + in := bytes.NewBufferString("help\nhelp 0\n help scope\nhelp 1\nhelp badcommand") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(50 * time.Millisecond) + + expected := `help to see commands linked to a given window +Active Scopes: [0] +Current Assumed Scope: 0 +General Commands: + fade: fade the specified renderable by the given int if given. Renderable must be registered in debug + help + scope: provide a scopeID to use commands without a scopeID prepended + +help to see commands linked to a given window +Active Scopes: [0] +Current Assumed Scope: 0 +General Commands: + fade: fade the specified renderable by the given int if given. Renderable must be registered in debug + help + scope: provide a scopeID to use commands without a scopeID prepended + +help to see commands linked to a given window +Active Scopes: [0] +Current Assumed Scope: 0 +Registered Instances of scope + scope0 scope: provide a scopeID to use commands without a scopeID prepended +inactive scope 1 see correct usage by using help without the scope +help to see commands linked to a given window +Active Scopes: [0] +Current Assumed Scope: 0 +Registered Instances of badcommand + Warning scope '0' did not have the specified command "badcommand" +` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } +} + +func TestScopedCommands_AttachToStream(t *testing.T) { + in := bytes.NewBufferString("simple") + out := new(bytes.Buffer) + + sc := NewScopedCommands() + sc.AttachToStream(context.Background(), in, out) + + // lazy interim approach for the async to complete + + time.Sleep(50 * time.Millisecond) + output := out.String() + if !strings.Contains(output, "Unknown command") { + t.Fatalf("attached Stream doesnt work %s\n", output) + } +} + +func TestScopedCommands_DetachFromStream(t *testing.T) { + in := new(bytes.Buffer) + out := new(bytes.Buffer) + + ctx, cancel := context.WithCancel(context.Background()) + + sc := NewScopedCommands() + sc.AttachToStream(ctx, in, out) + cancel() + time.Sleep(50 * time.Millisecond) + output := out.String() + if !strings.Contains(output, "stopping debugstream") { + t.Fatalf("unattaching Stream doesnt work %s\n", output) + } + +} diff --git a/debugstream/defaultcommands.go b/debugstream/defaultcommands.go new file mode 100644 index 00000000..3433e175 --- /dev/null +++ b/debugstream/defaultcommands.go @@ -0,0 +1,43 @@ +package debugstream + +import ( + "context" + "io" + "sync" + + "github.com/oakmound/oak/v3/window" +) + +var ( + // DefaultCommands to attach to. + DefaultCommands *ScopedCommands + defaultsOnce sync.Once +) + +func checkOrCreateDefaults() { + defaultsOnce.Do(func() { + DefaultCommands = NewScopedCommands() + }) +} + +// AddCommand to the default command set. +// See ScopedCommands' AddComand. +func AddCommand(c Command) error { + checkOrCreateDefaults() + return DefaultCommands.AddCommand(c) +} + +// AttachToStream if possible to start consuming the stream +// and executing commands per the stored infomraiton in the ScopeCommands. +func AttachToStream(ctx context.Context, input io.Reader, output io.Writer) { + checkOrCreateDefaults() + DefaultCommands.AttachToStream(ctx, input, output) +} + +// AddDefaultsForScope for debugging. +func AddDefaultsForScope(scopeID int32, controller interface{}) { + checkOrCreateDefaults() + if c, ok := controller.(window.Window); ok { + DefaultCommands.AddDefaultsForScope(scopeID, c) + } +} diff --git a/debugstream/doc.go b/debugstream/doc.go new file mode 100644 index 00000000..35e8c4d7 --- /dev/null +++ b/debugstream/doc.go @@ -0,0 +1,8 @@ +package debugstream + +// DebugStream is not intend for use in productionized apps. +// In general debugstream should be used to provide quick tools +// that can operate on a text stream such as standard Out. +// In some ways this is a stripped down event engine and perhaps should not exist. +// +// Note that all commands are currently treated as lowercase because paranoia. diff --git a/debugstream/mispellDetector.go b/debugstream/mispellDetector.go new file mode 100644 index 00000000..1950067c --- /dev/null +++ b/debugstream/mispellDetector.go @@ -0,0 +1,107 @@ +package debugstream + +const ( + unmatched = iota + potentialDuplicate = iota + transposed = iota + matched = iota +) + +const ( + prefixLen = 4 +) + +// jaroDecreased is a lightly modified version of Jaro +// While it takes inspiration from JaroWinkler this seemed more fun. +// Since this is intended for commands the lengths of the strings will be short. +// Presuppose that users will miss the end of a command or misappend extra data. +// Modified approach for domain that diverges from Jaro-Winkler's prefix strategy. +func jaroDecreased(candidate, registered string) (float64, float64) { + if len(candidate) == 0 { + return 0, 0 // probably shouldnt let it get to this step due to upstream constraints but adding for completeness + } + + totalMatches := 0.0 + transposed := 0.0 + + // denoted as match if within + matchingDist := len(candidate) + if matchingDist > len(registered) { + matchingDist = len(registered) + } + matchingDist = (matchingDist - 3) / 2 + if matchingDist < 0 { + matchingDist = 1 + } + + candidateCharStates := make([]int, len(candidate)) + registeredCharStates := make([]int, len(registered)) + // check for a potential match of every character in the candidate + for i := range candidate { + + start := i - matchingDist + if start < 0 { + start = 0 + } + end := i + matchingDist + if end >= len(registered) { + end = len(registered) - 1 + } + + // for our purposes lets be less mean to extra duplicates + + for j := start; j <= end; j++ { + + if candidate[i] == registered[j] { + if registeredCharStates[j] > unmatched { + candidateCharStates[i] = potentialDuplicate + transposed++ + continue + } + + if candidateCharStates[i] == potentialDuplicate { + transposed-- + } + candidateCharStates[i] = matched + registeredCharStates[j] = matched + totalMatches++ + break + } + } + } + if totalMatches == 0 { + return 0, 0 + } + + pseudoJaroVal := (totalMatches/float64(len(candidate)) + totalMatches/float64(len(registered)) + + (totalMatches-transposed)/totalMatches) / 3.0 + + prefixChecking := prefixLen + if len(candidate) < prefixLen { + prefixChecking = len(candidate) + } + boost := 0 + for i := 0; i < prefixChecking; i++ { + + if candidateCharStates[i] > potentialDuplicate { + boost++ + } + } + prefixBoostJaro := pseudoJaroVal + + // dont boost if its super low otherwise ful vs help will have a high boost. + if boost >= prefixChecking/2.0 { + boostFac := 1.0 + float64(boost)/10.0 + + prefixBoostJaro = (pseudoJaroVal * boostFac) + + } + + lengthBoost := 1.0 + float64(len(registered))/100.0 + prefixAndLengthBoosted := prefixBoostJaro * lengthBoost + if prefixAndLengthBoosted > 1 { + prefixAndLengthBoosted = 1 + } + + return pseudoJaroVal, prefixAndLengthBoosted +} diff --git a/debugstream/mispellDetector_test.go b/debugstream/mispellDetector_test.go new file mode 100644 index 00000000..6f2f0afb --- /dev/null +++ b/debugstream/mispellDetector_test.go @@ -0,0 +1,49 @@ +package debugstream + +import ( + "math" + "testing" +) + +func Test_jaroDecreased(t *testing.T) { + type args struct { + candidate string + registered string + } + tests := []struct { + name string + args args + want float64 + want2 float64 + }{ + + {"fullmatch", args{"super", "super"}, 1, 1}, + {"partial by paper", args{"CRATE", "TRACE"}, 2.2 / 3.0, (2.2 / 3.0) * 1.2 * 1.05}, + {"nomatch", args{"aaaaa", "super"}, 0, 0}, + {"empty", args{"", "super"}, 0, 0}, + + {"partialex", args{"afulls", "fullscreen"}, 7.0 / 9.0, 1}, + {"partialex2", args{"scope", "help"}, 3.0/20.0 + 1.0/3.0, (3.0/20.0 + 1.0/3.0) * 1.04}, + {"low", args{"full", "help"}, 0.5, 0.5 * 1.04}, + + {"transposes", args{"ooftypething", "rooftypething"}, 0.97435897, 1}, + + {"single", args{"f", "fullscreen"}, (2.1) / 3.0, (2.1) / 3.0 * 1.1 * 1.1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pseudoJ, psuedoJWithPref := jaroDecreased(tt.args.candidate, tt.args.registered) + if floatSig(4, pseudoJ) != floatSig(4, tt.want) { + t.Errorf("jaroDecreased of %s val = %v, wanted %v", tt.name, pseudoJ, tt.want) + } + if floatSig(4, psuedoJWithPref) != floatSig(4, tt.want2) { + t.Errorf("jaroDecreased of %s with boost = %v, wanted %v", tt.name, psuedoJWithPref, tt.want2) + } + }) + } +} + +func floatSig(bits int, target float64) float64 { + factor := math.Pow10(bits) + return math.Floor(target*factor) / factor +} diff --git a/debugstream/scopeHelper.go b/debugstream/scopeHelper.go new file mode 100644 index 00000000..1f82ff3e --- /dev/null +++ b/debugstream/scopeHelper.go @@ -0,0 +1,147 @@ +package debugstream + +import ( + "fmt" + "strconv" + "strings" + + "github.com/oakmound/oak/v3/collision" + "github.com/oakmound/oak/v3/dlog" + "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/mouse" + "github.com/oakmound/oak/v3/oakerr" + "github.com/oakmound/oak/v3/render" + "github.com/oakmound/oak/v3/render/mod" + "github.com/oakmound/oak/v3/window" +) + +// AddDefaultsForScope for debugging. +func (sc *ScopedCommands) AddDefaultsForScope(scopeID int32, controller window.Window) { + dlog.ErrorCheck(sc.AddCommand(Command{ScopeID: scopeID, Name: "fullscreen", Usage: explainFullScreen, Operation: fullScreen(controller)})) + dlog.ErrorCheck(sc.AddCommand(Command{ScopeID: scopeID, Name: "mouse-details", Usage: explainMouseDetails, Operation: mouseCommands(controller)})) + dlog.ErrorCheck(sc.AddCommand(Command{ScopeID: scopeID, Name: "quit", Usage: explainQuit, Operation: quitCommands(controller)})) + dlog.ErrorCheck(sc.AddCommand(Command{ScopeID: scopeID, Name: "skip-scene", Operation: skipCommands(controller)})) + dlog.ErrorCheck(sc.AddCommand(Command{ScopeID: scopeID, Name: "move", Operation: moveWindow(controller)})) + + if sc.assumedScope != 0 { + return + } + // assume the scope for easy usage here + sc.assumedScope = scopeID +} + +func moveWindow(w window.Window) func([]string) string { + return func(sub []string) string { + if len(sub) != 2 && len(sub) != 4 { + return oakerr.InsufficientInputs{ + AtLeast: 2, + InputName: "coordinates", + }.Error() + } + width := parseTokenAsInt(sub, 2, w.Width()) + height := parseTokenAsInt(sub, 3, w.Height()) + v := w.Viewport() + x := parseTokenAsInt(sub, 0, v.X()) + y := parseTokenAsInt(sub, 1, v.Y()) + w.MoveWindow(x, y, width, height) + return "" + } +} + +const explainFullScreen = "specify off 'fullscreen off' to exit fullscreen" + +func fullScreen(w window.Window) func([]string) string { + return func(sub []string) (out string) { + on := true + if len(sub) > 0 { + if sub[0] == "off" { + on = false + } + } + err := w.SetFullScreen(on) + dlog.ErrorCheck(err) + return + } +} + +const explainMouseDetails = "the mext mouse click on the given window will print the cursor's location" + +func mouseCommands(w window.Window) func([]string) string { + return func(tokenString []string) string { + w.EventHandler().GlobalBind("MouseRelease", mouseDetails(w)) + return "" + } +} + +func mouseDetails(w window.Window) func(event.CID, interface{}) int { + return func(nothing event.CID, mevent interface{}) int { + me := mevent.(mouse.Event) + viewPos := w.Viewport() + x := int(me.X()) + viewPos[0] + y := int(me.Y()) + viewPos[1] + loc := collision.NewUnassignedSpace(float64(x), float64(y), 16, 16) + results := collision.Hits(loc) + fmt.Println("Mouse at:", x, y, "rel:", me.X(), me.Y()) + if len(results) == 0 { + results = mouse.Hits(loc) + } + if len(results) > 0 { + i := int(results[0].CID) + if i > 0 && event.HasEntity(event.CID(i)) { + e := event.GetEntity(event.CID(i)) + fmt.Printf("%+v\n", e) + } else { + fmt.Println("No entity ", i) + } + } + + return event.UnbindSingle + } +} + +const explainQuit = "close the given window" + +func quitCommands(w window.Window) func([]string) string { + return func(tokenString []string) string { + w.Quit() + return "" + + } +} + +func skipCommands(w window.Window) func([]string) string { + return func(tokenString []string) string { + w.NextScene() + return "" + } +} + +const explainFade = "fade the specified renderable by the given int if given. Renderable must be registered in debug" + +func fadeCommands(tokenString []string) (out string) { + if len(tokenString) == 0 { + return oakerr.InsufficientInputs{ + AtLeast: 1, + InputName: "arguments", + }.Error() + "\n" + } + toFade, ok := render.GetDebugRenderable(tokenString[0]) + if ok { + fadeVal := parseTokenAsInt(tokenString, 1, 255) + toFade.(render.Modifiable).Filter(mod.Fade(fadeVal)) + return + } + out += fmt.Sprintf("Could not fade input %s\n", tokenString[0]) + + fmt.Sprintf("Possible inputs are '%s'\n", strings.Join(render.EnumerateDebugRenderableKeys(), ", ")) + return +} + +func parseTokenAsInt(tokenString []string, arrIndex int, defaultVal int) int { + if len(tokenString) > arrIndex { + tmp, err := strconv.Atoi(tokenString[arrIndex]) + if err == nil { + return tmp + } + } + return defaultVal +} diff --git a/debugstream/scopeHelper_test.go b/debugstream/scopeHelper_test.go new file mode 100644 index 00000000..232da465 --- /dev/null +++ b/debugstream/scopeHelper_test.go @@ -0,0 +1,204 @@ +package debugstream + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/oakmound/oak/v3/alg/intgeom" + "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/render" + "github.com/oakmound/oak/v3/window" +) + +type fakeWindow struct { + window.Window + + skipCalls int + quitCalls int + fullscreenCalls int + + moveWindow func(x, y, w, h int) +} + +func (f *fakeWindow) NextScene() { + f.skipCalls++ +} + +func (f *fakeWindow) Quit() { + f.quitCalls++ +} + +func (f *fakeWindow) EventHandler() event.Handler { + return event.NewBus(nil) +} + +func (f *fakeWindow) SetFullScreen(bool) error { + f.fullscreenCalls++ + return nil +} + +func (f *fakeWindow) MoveWindow(x, y, w, h int) error { + f.moveWindow(x, y, w, h) + return nil +} + +func (f *fakeWindow) Width() int { + return 1 +} + +func (f *fakeWindow) Height() int { + return 1 +} + +func (f *fakeWindow) Viewport() intgeom.Point2 { + return intgeom.Point2{} +} + +func TestSkipCommands(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + in := bytes.NewBufferString("skip-scene\n1 skip-scene\nscope 1\nskip-scene") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := `assumed scope 1 +` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } + if fw.skipCalls != 3 { + t.Fatal("expected 3 skips, got:", fw.skipCalls) + } +} + +func TestQuitCommands(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + in := bytes.NewBufferString("quit\n") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := `` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } + if fw.quitCalls != 1 { + t.Fatal("expected 1 quit, got:", fw.quitCalls) + } +} + +func TestFadeCommands(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + render.UpdateDebugMap("test-r", render.EmptyRenderable()) + + in := bytes.NewBufferString("fade test-r\nfade test-r 200\nfade\nfade bad-r") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := "Must supply at least 1 arguments\nCould not fade input bad-r\nPossible inputs are 'test-r'\n" + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } +} + +func TestMouseCommands(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + in := bytes.NewBufferString("mouse-details\n") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := `` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } +} + +func TestFullScreen(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + in := bytes.NewBufferString("fullscreen\nfullscreen off") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := `` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } + if fw.fullscreenCalls != 2 { + t.Fatal("expected 2 fullscreens, got:", fw.fullscreenCalls) + } +} + +func TestMoveWindow(t *testing.T) { + sc := NewScopedCommands() + fw := &fakeWindow{} + sc.AddDefaultsForScope(1, fw) + + fw.moveWindow = func(x, y, w, h int) { + if x != 1 { + t.Fatal("x was not 1:", x) + } + if y != 2 { + t.Fatal("y was not 2:", y) + } + if w != 3 { + t.Fatal("w was not 3:", w) + } + if h != 4 { + t.Fatal("h was not 4:", h) + } + } + + in := bytes.NewBufferString("move 1 2 3 4\nmove") + out := new(bytes.Buffer) + + sc.AttachToStream(context.Background(), in, out) + + time.Sleep(100 * time.Millisecond) + + expected := `Must supply at least 2 coordinates` + + got := out.String() + if got != expected { + t.Fatal("got:\n" + got + "\nexpected:\n" + expected) + } +} diff --git a/default.go b/default.go index 5239a9a5..05e4c5c0 100644 --- a/default.go +++ b/default.go @@ -2,6 +2,7 @@ package oak import ( "image" + "sync" "time" "github.com/oakmound/oak/v3/alg/intgeom" @@ -10,86 +11,114 @@ import ( "github.com/oakmound/oak/v3/scene" ) -var defaultController = NewController() +var defaultWindow *Window -func Init(scene string, configOptions ...ConfigOption) error { - defaultController.DrawStack = render.GlobalDrawStack - defaultController.logicHandler = event.DefaultBus - return defaultController.Init(scene, configOptions...) +var initDefaultWindowOnce sync.Once + +func initDefaultWindow() { + initDefaultWindowOnce.Do(func() { + defaultWindow = NewWindow() + }) } -func AddCommand(command string, fn func([]string)) error { - return defaultController.AddCommand(command, fn) +func Init(scene string, configOptions ...ConfigOption) error { + initDefaultWindow() + defaultWindow.DrawStack = render.GlobalDrawStack + defaultWindow.logicHandler = event.DefaultBus + return defaultWindow.Init(scene, configOptions...) } func AddScene(name string, sc scene.Scene) error { - return defaultController.AddScene(name, sc) + initDefaultWindow() + return defaultWindow.AddScene(name, sc) } func IsDown(key string) bool { - return defaultController.IsDown(key) + initDefaultWindow() + return defaultWindow.IsDown(key) } func IsHeld(key string) (bool, time.Duration) { - return defaultController.IsHeld(key) + initDefaultWindow() + return defaultWindow.IsHeld(key) } func SetUp(key string) { - defaultController.SetUp(key) + initDefaultWindow() + defaultWindow.SetUp(key) } func SetDown(key string) { - defaultController.SetDown(key) + initDefaultWindow() + defaultWindow.SetDown(key) } func SetViewportBounds(rect intgeom.Rect2) { - defaultController.SetViewportBounds(rect) + initDefaultWindow() + defaultWindow.SetViewportBounds(rect) } func ShiftScreen(x, y int) { - defaultController.ShiftScreen(x, y) + initDefaultWindow() + defaultWindow.ShiftScreen(x, y) } func SetScreen(x, y int) { - defaultController.SetScreen(x, y) + initDefaultWindow() + defaultWindow.SetScreen(x, y) } func SetFullScreen(fs bool) error { - return defaultController.SetFullScreen(fs) + initDefaultWindow() + return defaultWindow.SetFullScreen(fs) } func SetBorderless(bs bool) error { - return defaultController.SetBorderless(bs) + initDefaultWindow() + return defaultWindow.SetBorderless(bs) } func ScreenShot() *image.RGBA { - return defaultController.ScreenShot() + initDefaultWindow() + return defaultWindow.ScreenShot() } func SetLoadingRenderable(r render.Renderable) { - defaultController.SetLoadingRenderable(r) + initDefaultWindow() + defaultWindow.SetLoadingRenderable(r) } func SetBackground(b Background) { - defaultController.SetBackground(b) + initDefaultWindow() + defaultWindow.SetBackground(b) } func SetColorBackground(img image.Image) { - defaultController.SetColorBackground(img) + initDefaultWindow() + defaultWindow.SetColorBackground(img) } func GetBackgroundImage() image.Image { - return defaultController.GetBackgroundImage() + initDefaultWindow() + return defaultWindow.GetBackgroundImage() } func Width() int { - return defaultController.Width() + initDefaultWindow() + return defaultWindow.Width() } func Height() int { - return defaultController.Height() + initDefaultWindow() + return defaultWindow.Height() } func HideCursor() error { - return defaultController.HideCursor() + initDefaultWindow() + return defaultWindow.HideCursor() +} + +func GetCursorPosition() (x, y float64, err error) { + initDefaultWindow() + return defaultWindow.GetCursorPosition() } diff --git a/dlog/default.go b/dlog/default.go index 0fcd70b4..cad59da5 100644 --- a/dlog/default.go +++ b/dlog/default.go @@ -1,26 +1,27 @@ package dlog import ( - "bufio" "bytes" "fmt" + "io" "os" "runtime" "strconv" "strings" "sync" - "time" + + "github.com/oakmound/oak/v3/oakerr" ) var ( - _ FullLogger = &logger{} + _ Logger = &logger{} ) type logger struct { bytPool sync.Pool debugLevel Level - debugFilter string - writer *bufio.Writer + debugFilter func(string) bool + writer io.Writer } // NewLogger returns an instance of the default logger with no filter, @@ -33,6 +34,7 @@ func NewLogger() Logger { }, }, debugLevel: ERROR, + writer: os.Stdout, } } @@ -48,7 +50,7 @@ func (l *logger) GetLogLevel() Level { // It only includes logs which pass the current filters. // Todo: use io.Multiwriter to simplify the writing to // both logfiles and stdout -func (l *logger) dLog(level Level, console, override bool, in ...interface{}) { +func (l *logger) dLog(level Level, in ...interface{}) { //(pc uintptr, file string, line int, ok bool) _, f, line, ok := runtime.Caller(2) if strings.Contains(f, "dlog") { @@ -56,7 +58,7 @@ func (l *logger) dLog(level Level, console, override bool, in ...interface{}) { } if ok { f = truncateFileName(f) - if !l.checkFilter(f, in) && !override { + if !l.checkFilter(f, in) { return } @@ -71,98 +73,73 @@ func (l *logger) dLog(level Level, console, override bool, in ...interface{}) { buffer.WriteString(logLevels[level]) buffer.WriteRune(':') for _, elem := range in { - buffer.WriteString(fmt.Sprintf("%v ", elem)) + buffer.WriteString(fmt.Sprintf(" %v", elem)) } buffer.WriteRune('\n') - if console { - fmt.Print(buffer.String()) - } - - if l.writer != nil { - l.writer.WriteString(buffer.String()) - l.writer.Flush() - } + // This can error, but we can't do anything about it if it does. + l.writer.Write(buffer.Bytes()) buffer.Reset() l.bytPool.Put(buffer) } } -// FileWrite runs dLog, but JUST writes to file instead -// of also to stdout. -func (l *logger) FileWrite(in ...interface{}) { - l.dLog(INFO, false, true, in...) -} - func truncateFileName(f string) string { - index := strings.LastIndex(f, "/") - lIndex := strings.LastIndex(f, ".") - return f[index+1 : lIndex] + directoryIndex := strings.LastIndex(f, "/") + extensionIndex := strings.LastIndex(f, ".") + return f[directoryIndex+1 : extensionIndex] } func (l *logger) checkFilter(f string, in ...interface{}) bool { + if l.debugFilter == nil { + return true + } + check := f for _, elem := range in { - if strings.Contains(fmt.Sprintf("%s", elem), l.debugFilter) { - return true - } + check += fmt.Sprintf(" %v", elem) } - return strings.Contains(f, l.debugFilter) + return l.debugFilter(check) } -// SetDebugFilter sets the string which determines -// what debug messages get printed. Only messages -// which contain the filer as a pseudo-regex -func (l *logger) SetDebugFilter(filter string) { +// SetFilter defines a custom filter function. Log lines that +// return false when passed to this function will not be output. +func (l *logger) SetFilter(filter func(string) bool) { l.debugFilter = filter } -// SetDebugLevel sets what message levels of debug +// SetLogLevel sets what message levels of debug // will be printed. -func (l *logger) SetDebugLevel(dL Level) { - if dL < NONE || dL > VERBOSE { - Warn("Unknown debug level: ", dL) - l.debugLevel = NONE +func (l *logger) SetLogLevel(level Level) error { + if level < NONE || level > VERBOSE { + return oakerr.InvalidInput{} } else { - l.debugLevel = dL - } -} - -// CreateLogFile creates a file in the 'logs' directory -// of the starting point of this program to write logs to -func (l *logger) CreateLogFile() { - fHandle, err := os.Create("logs/dlog" + time.Now().Format("_Jan_2_15-04-05_2006") + ".txt") - if err != nil { - fmt.Println("[oak]-------- No logs directory found. No logs will be written to file.") - return + l.debugLevel = level } - l.writer = bufio.NewWriter(fHandle) + return nil } // Error will write a dlog if the debug level is not NONE func (l *logger) Error(in ...interface{}) { if l.debugLevel > NONE { - l.dLog(ERROR, true, true, in...) - } -} - -// Warn will write a dLog if the debug level is higher than ERROR -func (l *logger) Warn(in ...interface{}) { - if l.debugLevel > ERROR { - l.dLog(WARN, true, true, in...) + l.dLog(ERROR, in...) } } // Info will write a dLog if the debug level is higher than WARN func (l *logger) Info(in ...interface{}) { - if l.debugLevel > WARN { - l.dLog(INFO, true, false, in...) + if l.debugLevel > ERROR { + l.dLog(INFO, in...) } } // Verb will write a dLog if the debug level is higher than INFO func (l *logger) Verb(in ...interface{}) { if l.debugLevel > INFO { - l.dLog(VERBOSE, true, false, in...) + l.dLog(VERBOSE, in...) } } + +func (l *logger) SetOutput(w io.Writer) { + l.writer = w +} diff --git a/dlog/default_test.go b/dlog/default_test.go index 3d1b709a..743b0faf 100644 --- a/dlog/default_test.go +++ b/dlog/default_test.go @@ -1,56 +1,51 @@ -package dlog +package dlog_test import ( - "bufio" "bytes" + "strings" "testing" + + "github.com/oakmound/oak/v3/dlog" ) func TestLogger(t *testing.T) { - lgr := NewLogger().(*logger) + lgr := dlog.NewLogger() defaultLevel := lgr.GetLogLevel() - if defaultLevel != ERROR { + if defaultLevel != dlog.ERROR { t.Fatalf("expected default log level to be ERROR, was: %v", defaultLevel) } - lgr.SetDebugLevel(-1) - if lgr.GetLogLevel() != NONE { - t.Fatalf("expected -1 log level to be NONE, was: %v", lgr.GetLogLevel()) + err := lgr.SetLogLevel(-1) + if err == nil { + t.Fatalf("expected -1 log level to error") } - lgr.SetDebugLevel(VERBOSE) + lgr.SetLogLevel(dlog.VERBOSE) var buff = new(bytes.Buffer) - lgr.writer = bufio.NewWriter(buff) - - callLogger := func() { - lgr.FileWrite("fileWrite") + lgr.SetOutput(buff) + // This function wrapper corrects the logged file generated + calllogger := func() { lgr.Error("error") - lgr.Warn("warn") lgr.Info("info") lgr.Verb("verb") - lgr.SetDebugFilter("foo") + lgr.SetFilter(func(s string) bool { return strings.Contains(s, "foo") }) lgr.Verb("bar") lgr.Verb("foo") } - callLogger() - - expectedOut := `[default_test:39] INFO:fileWrite -[default_test:39] ERROR:error -[default_test:39] WARN:warn -[default_test:39] INFO:info -[default_test:39] VERBOSE:verb -[default_test:39] VERBOSE:foo -` + calllogger() - out := string(buff.Bytes()) + expectedOut := `[default_test:39] ERROR: error +[default_test:39] INFO: info +[default_test:39] VERBOSE: verb +[default_test:39] VERBOSE: foo +` + out := buff.String() if out != expectedOut { t.Fatalf("logged output did not match: got %q expected %q", out, expectedOut) } - - lgr.CreateLogFile() } diff --git a/dlog/dlog.go b/dlog/dlog.go index 8df02938..3d557b59 100644 --- a/dlog/dlog.go +++ b/dlog/dlog.go @@ -1,23 +1,23 @@ package dlog import ( - "errors" - "strings" + "io" ) // A Logger is a minimal log interface for the content oak wants to log: // four levels of logging. type Logger interface { Error(...interface{}) - Warn(...interface{}) Info(...interface{}) Verb(...interface{}) + SetFilter(func(string) bool) + GetLogLevel() Level + SetLogLevel(l Level) error + SetOutput(io.Writer) } -// OakLogger is the Logger which all oak log functions are passed through. -// If this is not manually set through SetLogger, oak will initialize this -// to the an instance of the private logger type -var oakLogger Logger +// DefaultLogger is the Logger which all oak log messages are passed through. +var DefaultLogger Logger = NewLogger() // ErrorCheck checks that the input is not nil, then calls Error on it if it is // not. Otherwise it does nothing. @@ -30,61 +30,36 @@ func ErrorCheck(in error) error { } // Error will write a log if the debug level is not NONE -var Error = func(...interface{}) {} - -// Warn will write a log if the debug level is higher than ERROR -var Warn = func(...interface{}) {} +func Error(vs ...interface{}) { + DefaultLogger.Error(vs...) +} -// Info will write a log if the debug level is higher than WARN -var Info = func(...interface{}) {} +// Info will write a log if the debug level is higher than ERROR +func Info(vs ...interface{}) { + DefaultLogger.Info(vs...) +} // Verb will write a log if the debug level is higher than INFO -var Verb = func(...interface{}) {} +func Verb(vs ...interface{}) { + DefaultLogger.Verb(vs...) +} -// SetLogger defines what logger should be used for oak's internal logging. -// If this is NOT called before oak.Init is called (assuming this is being -// used with oak), then it will be called with the default logger as a part -// of oak.Init. -func SetLogger(l Logger) { - _, isDefault := l.(*logger) - if isDefault && oakLogger != nil { - // The user set the logger themselves, - // don't reset to the default logger - return - } - oakLogger = l - Error = l.Error - Warn = l.Warn - Info = l.Info - Verb = l.Verb - // If this logger supports the additional functionality described - // by the FullLogger interface, enable those functions. Otherwise - // they are NOPs. (the default logger supports these functions.) - if fl, ok := l.(FullLogger); ok { - fullOakLogger = fl - FileWrite = fl.FileWrite - GetLogLevel = fl.GetLogLevel - SetDebugFilter = fl.SetDebugFilter - SetDebugLevel = fl.SetDebugLevel - CreateLogFile = fl.CreateLogFile - } +// GetLogLevel returns the set logger's log level +func GetLogLevel() Level { + return DefaultLogger.GetLogLevel() } -// ParseDebugLevel parses the input string as a known debug levels -func ParseDebugLevel(s string) (Level, error) { - s = strings.ToUpper(s) - switch s { - case "INFO": - return INFO, nil - case "VERBOSE": - return VERBOSE, nil - case "ERROR": - return ERROR, nil - case "WARN": - return WARN, nil - case "NONE": - return NONE, nil - default: - return ERROR, errors.New("parsing dlog level of \"" + s + "\" failed") - } +// SetFilter defines a custom filter function. Log lines that +// return false when passed to this function will not be output. +func SetFilter(filter func(string) bool) { + DefaultLogger.SetFilter(filter) +} + +// SetLogLevel sets the log level of the default logger. +func SetLogLevel(l Level) error { + return DefaultLogger.SetLogLevel(l) +} + +func SetOutput(w io.Writer) { + DefaultLogger.SetOutput(w) } diff --git a/dlog/dlog_private_test.go b/dlog/dlog_private_test.go deleted file mode 100644 index ef3018d4..00000000 --- a/dlog/dlog_private_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package dlog - -import "testing" - -func TestSetCustomLogger(t *testing.T) { - type customLogger struct { - FullLogger - } - cl := customLogger{} - SetLogger(cl) - - SetLogger(&logger{}) - - _, isCustom := oakLogger.(customLogger) - if !isCustom { - t.Fatal("custom logger should not have been overwritten by default logger") - } - - oakLogger = nil - fullOakLogger = nil -} diff --git a/dlog/dlog_test.go b/dlog/dlog_test.go index f7c29ffe..edd980e6 100644 --- a/dlog/dlog_test.go +++ b/dlog/dlog_test.go @@ -1,26 +1,26 @@ package dlog_test import ( + "bytes" "fmt" + "os" "testing" "github.com/oakmound/oak/v3/dlog" ) func TestErrorCheck(t *testing.T) { - called := false - dlog.Error = func(...interface{}) { - called = true - } + buff := &bytes.Buffer{} + dlog.DefaultLogger.SetOutput(buff) dlog.ErrorCheck(nil) - if called { + if buff.Len() != 0 { t.Fatal("error should not have been called on nil error") } dlog.ErrorCheck(fmt.Errorf("err")) - if !called { + if buff.Len() == 0 { t.Fatal("error should have been called on real error") } - dlog.Error = func(...interface{}) {} + dlog.DefaultLogger.SetOutput(os.Stdout) } func TestParseDebugLevel(t *testing.T) { @@ -43,16 +43,13 @@ func TestParseDebugLevel(t *testing.T) { }, { in: "ERROR", outLevel: dlog.ERROR, - }, { - in: "warN", - outLevel: dlog.WARN, }, { in: "none", outLevel: dlog.NONE, }, { in: "other", outErrors: true, - outErrorStr: "parsing dlog level of \"OTHER\" failed", + outErrorStr: "invalid input: level", }, } for _, tc := range tcs { @@ -76,3 +73,15 @@ func TestParseDebugLevel(t *testing.T) { }) } } + +func TestDefaultFunctions(t *testing.T) { + // coverage tests, functionality tested elsewhere + dlog.Info("test") + dlog.Verb("test") + dlog.SetLogLevel(dlog.VERBOSE) + if dlog.GetLogLevel() != dlog.VERBOSE { + t.Fatalf("GetLogLevel did not match Set") + } + dlog.SetOutput(os.Stderr) + dlog.SetFilter(func(s string) bool { return false }) +} diff --git a/dlog/doc.go b/dlog/doc.go index 952d837e..548fa6e2 100644 --- a/dlog/doc.go +++ b/dlog/doc.go @@ -1,3 +1,8 @@ // Package dlog provides logging functions with caller file and line information, // logging levels and level and text filters. +// +// It is not intended to be a fully featured or fully optimized logger-- it is +// just enough of a logger for oak's needs. A program utilizing oak, if it wants +// more powerful logs, should log to a more powerful tool, and if desired, tell oak +// to as well via setting dlog.DefaultLogger. package dlog diff --git a/dlog/fullLogger.go b/dlog/fullLogger.go deleted file mode 100644 index 3949ffda..00000000 --- a/dlog/fullLogger.go +++ /dev/null @@ -1,41 +0,0 @@ -package dlog - -// A FullLogger supports, in addition to Logger's functions, -// the ability to set and get a log level, and create and write -// logs directly to file. -type FullLogger interface { - Logger - GetLogLevel() Level - FileWrite(in ...interface{}) - SetDebugFilter(filter string) - SetDebugLevel(dL Level) - CreateLogFile() -} - -var fullOakLogger FullLogger - -// GetLogLevel returns the log level of the fullOakLogger, or -// NONE if there is no fullOakLogger. -var GetLogLevel = func() Level { - return NONE -} - -// FileWrite logs by writing to file (if possible) but does -// not log to console as well. -// This is a NOP if fullOakLogger is not set by SetLogger. -var FileWrite = func(...interface{}) {} - -// SetDebugFilter defines a string that all logs should be -// checked against-- if the log message does not contain -// the input string the log will not log. -// This is a NOP if fullOakLogger is not set by SetLogger. -var SetDebugFilter = func(string) {} - -// SetDebugLevel sets the log level of the fullOakLogger. -// This is a NOP if fullOakLogger is not set by SetLogger. -var SetDebugLevel = func(Level) {} - -// CreateLogFile creates a file for logs to be written to -// by log functions. -// This is a NOP if fullOakLogger is not set by SetLogger. -var CreateLogFile = func() {} diff --git a/dlog/levels.go b/dlog/levels.go index 789f914e..01559a89 100644 --- a/dlog/levels.go +++ b/dlog/levels.go @@ -1,5 +1,11 @@ package dlog +import ( + "strings" + + "github.com/oakmound/oak/v3/oakerr" +) + // Level represents the levels a debug message can have type Level int @@ -7,7 +13,6 @@ type Level int const ( NONE Level = iota ERROR - WARN INFO VERBOSE ) @@ -15,7 +20,6 @@ const ( var logLevels = map[Level]string{ NONE: "NONE", ERROR: "ERROR", - WARN: "WARN", INFO: "INFO", VERBOSE: "VERBOSE", } @@ -23,3 +27,20 @@ var logLevels = map[Level]string{ func (l Level) String() string { return logLevels[l] } + +// ParseDebugLevel parses the input string as a known debug levels +func ParseDebugLevel(level string) (Level, error) { + level = strings.ToUpper(level) + switch level { + case "INFO": + return INFO, nil + case "VERBOSE": + return VERBOSE, nil + case "ERROR": + return ERROR, nil + case "NONE": + return NONE, nil + default: + return ERROR, oakerr.InvalidInput{InputName: "level"} + } +} diff --git a/dlog/levels_test.go b/dlog/levels_test.go index 54fc572b..bd3cb585 100644 --- a/dlog/levels_test.go +++ b/dlog/levels_test.go @@ -2,10 +2,10 @@ package dlog_test import ( "testing" + "github.com/oakmound/oak/v3/dlog" ) - func TestLevelsString(t *testing.T) { type testCase struct { in dlog.Level @@ -18,9 +18,6 @@ func TestLevelsString(t *testing.T) { }, { in: dlog.ERROR, out: "ERROR", - }, { - in: dlog.WARN, - out: "WARN", }, { in: dlog.INFO, out: "INFO", diff --git a/dlog/regexLogger.go b/dlog/regexLogger.go deleted file mode 100644 index 7a73298a..00000000 --- a/dlog/regexLogger.go +++ /dev/null @@ -1,179 +0,0 @@ -package dlog - -import ( - "bytes" - "fmt" - "io" - "os" - "regexp" - "runtime" - "strconv" - "strings" - "time" -) - -var ( - _ FullLogger = &RegexLogger{} -) - -// RegexLogger is a logger implementation that offers some -// additional features on top of the default logger. -// Todo v3: combine logger implementations. -type RegexLogger struct { - debugLevel Level - - debugFilter string - filterRegex *regexp.Regexp - // FilterOverrideLevel is the log level at which - // logs will be shown regardless of the filter. - FilterOverrideLevel Level - - writer io.Writer - file io.Writer -} - -// NewRegexLogger returns a custom logger that writes to os.Stdout and -// overrides filters on WARN or higher messages. -func NewRegexLogger(level Level) *RegexLogger { - return &RegexLogger{ - debugLevel: level, - writer: os.Stdout, - FilterOverrideLevel: WARN, - } -} - -// GetLogLevel returns the current log level, i.e WARN or INFO... -func (l *RegexLogger) GetLogLevel() Level { - return l.debugLevel -} - -// dLog, the primary function of the package, -// prints out and writes to file a string -// containing the logged data separated by spaces, -// prepended with file and line information. -// It only includes logs which pass the current filters. -func (l *RegexLogger) dLog(w io.Writer, override bool, in ...interface{}) { - _, file, line, ok := runtime.Caller(2) - // TODO oak v3: more precise ruintime caller counting - if strings.Contains(file, "dlog") { - _, file, line, ok = runtime.Caller(3) - } - if ok { - var bldr strings.Builder - file = truncateFileName(file) - // Note on errors: these functions all return - // errors, but they are always nil. - bldr.WriteRune('[') - bldr.WriteString(file) - bldr.WriteRune(':') - bldr.WriteString(strconv.Itoa(line)) - bldr.WriteString("] ") - bldr.WriteString(logLevels[l.GetLogLevel()]) - bldr.WriteRune(':') - for _, elem := range in { - bldr.WriteString(fmt.Sprintf("%v ", elem)) - } - bldr.WriteRune('\n') - fullLog := []byte(bldr.String()) - - if !override && !l.checkFilter(fullLog) { - return - } - - _, err := w.Write(fullLog) - if err != nil { - fmt.Println("Logging error", err) - } - } -} - -func (l *RegexLogger) checkFilter(fullLog []byte) bool { - if l.debugFilter == "" { - return true - } - if l.filterRegex != nil { - return l.filterRegex.Match(fullLog) - } - return bytes.Contains(fullLog, []byte(l.debugFilter)) -} - -// SetDebugFilter sets the string which determines -// what debug messages get printed. Only messages -// which contain the filer as a pseudo-regex -func (l *RegexLogger) SetDebugFilter(filter string) { - l.debugFilter = filter - var err error - l.filterRegex, err = regexp.Compile(filter) - if err != nil { - l.Error("could not compile filter regex", err) - } -} - -// SetDebugLevel sets what message levels of debug -// will be printed. -func (l *RegexLogger) SetDebugLevel(dL Level) { - if dL < NONE || dL > VERBOSE { - l.Warn("Unknown debug level: ", dL) - l.debugLevel = NONE - } else { - l.debugLevel = dL - } -} - -// CreateLogFile creates a file in the 'logs' directory -// of the starting point of this program to write logs to -func (l *RegexLogger) CreateLogFile() { - var err error - l.file, err = os.Create("logs/dlog" + time.Now().Format("_Jan_2_15-04-05_2006") + ".txt") - if err != nil { - fmt.Println("[oak]-------- No logs directory found. No logs will be written to file.") - return - } - l.writer = io.MultiWriter(l.file, l.writer) -} - -// FileWrite acts just like a regular write on a RegexLogger. It does -// not respect overrides. -func (l *RegexLogger) FileWrite(in ...interface{}) { - if l.file == nil { - return - } - l.dLog(l.file, true, in...) -} - -// Error will write a dlog if the debug level is not NONE -func (l *RegexLogger) Error(in ...interface{}) { - if l.debugLevel > NONE { - l.dLog(l.writer, l.FilterOverrideLevel > NONE, in) - } -} - -// Warn will write a dLog if the debug level is higher than ERROR -func (l *RegexLogger) Warn(in ...interface{}) { - if l.debugLevel > ERROR { - l.dLog(l.writer, l.FilterOverrideLevel > ERROR, in) - } -} - -// Info will write a dLog if the debug level is higher than WARN -func (l *RegexLogger) Info(in ...interface{}) { - if l.debugLevel > WARN { - l.dLog(l.writer, l.FilterOverrideLevel > WARN, in) - } -} - -// Verb will write a dLog if the debug level is higher than INFO -func (l *RegexLogger) Verb(in ...interface{}) { - if l.debugLevel > INFO { - l.dLog(l.writer, l.FilterOverrideLevel > INFO, in) - } -} - -// SetWriter sets the writer that RegexLogger logs to -func (l *RegexLogger) SetWriter(w io.Writer) error { - if w == nil { - return fmt.Errorf("cannot write to nil writer") - } - l.writer = w - return nil -} diff --git a/dlog/regexLogger_test.go b/dlog/regexLogger_test.go deleted file mode 100644 index a2c6f3ae..00000000 --- a/dlog/regexLogger_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package dlog - -import ( - "bytes" - "strings" - "testing" -) - -func TestNewRegexLogger(t *testing.T) { - cl := NewRegexLogger(NONE) - if cl.debugLevel != NONE { - t.Fatalf("expected %v debug level, got %v", NONE, cl.debugLevel) - } - if cl.FilterOverrideLevel != WARN { - t.Fatalf("expected %v override level, got %v", WARN, cl.FilterOverrideLevel) - } -} - -func TestRegexLogger_GetLogLevel(t *testing.T) { - cl := NewRegexLogger(NONE) - level := cl.GetLogLevel() - if level != NONE { - t.Fatalf("expected %v debug level, got %v", NONE, level) - } -} - -func testRegexLoggerContains(t *testing.T, f func(cl *RegexLogger, buff *bytes.Buffer), s string) { - cl := NewRegexLogger(VERBOSE) - buff := bytes.NewBuffer([]byte{}) - cl.SetWriter(buff) - f(cl, buff) - if len(s) == 0 { - bs := buff.String() - if bs != "" { - t.Fatalf("expected empty string, got %v", bs) - } - } else { - bs := buff.String() - if !strings.Contains(bs, s) { - t.Fatalf("expected buffer to contain %v, got %v", s, bs) - } - } -} - -func TestRegexLogger_SetDebugFilter(t *testing.T) { - type testCase struct { - name string - fn func(cl *RegexLogger, buff *bytes.Buffer) - contains string - } - tcs := []testCase{ - { - name: "valid regex: no match", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("myfilter(1)+") - cl.Verb("20145") - }, - }, { - name: "valid regex: no match 2", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("myfilter(1)+") - cl.Verb("myfilter") - }, - }, { - name: "valid regex: match", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("myfilter(1)+") - cl.Verb("myfilter11111") - }, - contains: "myfilter11111", - }, { - name: "invalid regex", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("([1-9)+") - }, - contains: "could not compile filter regex", - }, { - name: "invalid regex: no match", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("([1-9)+") - buff.Reset() - cl.Verb("1423") - }, - }, { - name: "invalid regex: match", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("([1-9)+") - buff.Reset() - cl.Verb("([1-9)+") - }, - contains: "([1-9)+", - }, - } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - testRegexLoggerContains(t, tc.fn, tc.contains) - }) - } -} - -func TestRegexLogger_FilterOverrideLevel(t *testing.T) { - type testCase struct { - name string - fn func(cl *RegexLogger, buff *bytes.Buffer) - contains string - } - tcs := []testCase{ - { - name: "default override: too low does not emit", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("filter1") - cl.Verb("does not contain") - }, - }, { - name: "default override: emits", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("filter1") - cl.Warn("does not contain") - }, - contains: "does not contain", - }, { - name: "custom override: too low does not emit", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("filter1") - cl.FilterOverrideLevel = ERROR - cl.Warn("does not contain") - }, - }, { - name: "custom override: emits", - fn: func(cl *RegexLogger, buff *bytes.Buffer) { - cl.SetDebugFilter("filter1") - cl.FilterOverrideLevel = ERROR - cl.Error("does not contain") - }, - contains: "does not contain", - }, - } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - testRegexLoggerContains(t, tc.fn, tc.contains) - }) - } -} - -func TestRegexLogger_SetDebugLevel(t *testing.T) { - cl := NewRegexLogger(NONE) - cl.SetDebugLevel(INFO) - if cl.debugLevel != INFO { - t.Fatalf("expected %v debug level, got %v", INFO, cl.debugLevel) - } - - level := cl.GetLogLevel() - if level != INFO { - t.Fatalf("expected %v debug level, got %v", INFO, level) - } -} - -func TestRegexLogger_CreateLogFile(t *testing.T) { - cl := NewRegexLogger(NONE) - cl.CreateLogFile() -} - -func TestRegexLogger_FileWrite(t *testing.T) { - cl := NewRegexLogger(NONE) - buff := bytes.NewBuffer([]byte{}) - cl.FileWrite("whoops") - cl.file = buff - cl.FileWrite("test") - bs := buff.String() - if strings.Contains(bs, "whoops") { - t.Fatalf("expected buffer not to contain %v, was %v", "whoops", bs) - } - if !strings.Contains(bs, "test") { - t.Fatalf("expected buffer to contain %v, was %v", "test", bs) - } -} - -func TestRegexLogger_Error(t *testing.T) { - cl := NewRegexLogger(ERROR) - buff := bytes.NewBuffer([]byte{}) - cl.SetWriter(buff) - cl.Verb("verbose") - cl.Info("info") - cl.Warn("warn") - cl.Error("error") - bs := buff.String() - if strings.Contains(bs, "verbose") { - t.Fatalf("expected buffer not to contain %v, was %v", "verbose", bs) - } - if strings.Contains(bs, "info") { - t.Fatalf("expected buffer not to contain %v, was %v", "info", bs) - } - if strings.Contains(bs, "warn") { - t.Fatalf("expected buffer not to contain %v, was %v", "warn", bs) - } - if !strings.Contains(bs, "error") { - t.Fatalf("expected buffer to contain %v, was %v", "error", bs) - } -} - -func TestRegexLogger_Warn(t *testing.T) { - cl := NewRegexLogger(WARN) - buff := bytes.NewBuffer([]byte{}) - cl.SetWriter(buff) - cl.Verb("verbose") - cl.Info("info") - cl.Warn("warn") - cl.Error("error") - bs := buff.String() - if strings.Contains(bs, "verbose") { - t.Fatalf("expected buffer not to contain %v, was %v", "verbose", bs) - } - if strings.Contains(bs, "info") { - t.Fatalf("expected buffer not to contain %v, was %v", "info", bs) - } - if !strings.Contains(bs, "warn") { - t.Fatalf("expected buffer to contain %v, was %v", "warn", bs) - } - if !strings.Contains(bs, "error") { - t.Fatalf("expected buffer to contain %v, was %v", "error", bs) - } -} - -func TestRegexLogger_Info(t *testing.T) { - cl := NewRegexLogger(INFO) - buff := bytes.NewBuffer([]byte{}) - cl.SetWriter(buff) - cl.Verb("verbose") - cl.Info("info") - cl.Warn("warn") - cl.Error("error") - bs := buff.String() - if strings.Contains(bs, "verbose") { - t.Fatalf("expected buffer not to contain %v, was %v", "verbose", bs) - } - if !strings.Contains(bs, "info") { - t.Fatalf("expected buffer to contain %v, was %v", "info", bs) - } - if !strings.Contains(bs, "warn") { - t.Fatalf("expected buffer to contain %v, was %v", "warn", bs) - } - if !strings.Contains(bs, "error") { - t.Fatalf("expected buffer to contain %v, was %v", "error", bs) - } -} - -func TestRegexLogger_Verb(t *testing.T) { - cl := NewRegexLogger(VERBOSE) - buff := bytes.NewBuffer([]byte{}) - cl.SetWriter(buff) - cl.Verb("verbose") - cl.Info("info") - cl.Warn("warn") - cl.Error("error") - bs := buff.String() - if !strings.Contains(bs, "verbose") { - t.Fatalf("expected buffer to contain %v, was %v", "verbose", bs) - } - if !strings.Contains(bs, "info") { - t.Fatalf("expected buffer to contain %v, was %v", "info", bs) - } - if !strings.Contains(bs, "warn") { - t.Fatalf("expected buffer to contain %v, was %v", "warn", bs) - } - if !strings.Contains(bs, "error") { - t.Fatalf("expected buffer to contain %v, was %v", "error", bs) - } -} - -func TestRegexLogger_SetWriter(t *testing.T) { - cl := NewRegexLogger(VERBOSE) - err := cl.SetWriter(nil) - if err == nil { - t.Fatalf("expected setWriter(nil) to error") - } - err = cl.SetWriter(bytes.NewBuffer([]byte{})) - if err != nil { - t.Fatalf("expected setWriter([]byte) not to error: %v", err) - } -} diff --git a/doc_test.go b/doc_test.go index 8c0c007f..4e03e9af 100644 --- a/doc_test.go +++ b/doc_test.go @@ -18,21 +18,3 @@ func Example() { }}) Init("basicScene") } - -// Use AddCommand to grant access to command line commands. Often used to toggle debug modes. -func ExampleAddCommand() { - debug := true - AddCommand("SetDebug", func(args []string) { - - if len(args) == 0 { - debug = !debug - } - switch args[0][:1] { - case "t", "T": - debug = true - case "f", "F": - debug = false - } - - }) -} diff --git a/drawLoop.go b/drawLoop.go index d54f6d8e..5ec58903 100644 --- a/drawLoop.go +++ b/drawLoop.go @@ -15,41 +15,57 @@ type Background interface { // 2. draw all visible rendered elements onto the temporary buffer. // 3. draw the buffer's data at the viewport's position to the screen. // 4. publish the screen to display in window. -func (c *Controller) drawLoop() { - <-c.drawCh +func (w *Window) drawLoop() { + <-w.drawCh - draw.Draw(c.winBuffer.RGBA(), c.winBuffer.Bounds(), c.bkgFn(), zeroPoint, draw.Src) - c.publish() + draw.Draw(w.winBuffer.RGBA(), w.winBuffer.Bounds(), w.bkgFn(), zeroPoint, draw.Src) + w.publish() for { drawSelect: select { - case <-c.drawCh: - <-c.drawCh + case <-w.quitCh: + return + case <-w.drawCh: + <-w.drawCh for { select { - case <-c.drawCh: + case <-w.ParentContext.Done(): + return + case <-w.quitCh: + return + case <-w.drawCh: break drawSelect - case <-c.DrawTicker.C: - draw.Draw(c.winBuffer.RGBA(), c.winBuffer.Bounds(), c.bkgFn(), zeroPoint, draw.Src) - if c.LoadingR != nil { - c.LoadingR.Draw(c.winBuffer.RGBA(), 0, 0) + case <-w.DrawTicker.C: + draw.Draw(w.winBuffer.RGBA(), w.winBuffer.Bounds(), w.bkgFn(), zeroPoint, draw.Src) + if w.LoadingR != nil { + w.LoadingR.Draw(w.winBuffer.RGBA(), 0, 0) } - c.publish() + w.publish() } } - case <-c.DrawTicker.C: - draw.Draw(c.winBuffer.RGBA(), c.winBuffer.Bounds(), c.bkgFn(), zeroPoint, draw.Src) - c.DrawStack.PreDraw() - c.DrawStack.DrawToScreen(c.winBuffer.RGBA(), c.viewPos, c.ScreenWidth, c.ScreenHeight) - c.publish() + case f := <-w.betweenDrawCh: + f() + case <-w.DrawTicker.C: + draw.Draw(w.winBuffer.RGBA(), w.winBuffer.Bounds(), w.bkgFn(), zeroPoint, draw.Src) + w.DrawStack.PreDraw() + p := w.viewPos + w.DrawStack.DrawToScreen(w.winBuffer.RGBA(), &p, w.ScreenWidth, w.ScreenHeight) + w.publish() } } } -func (c *Controller) publish() { - c.prePublish(c, c.windowTexture) - c.windowTexture.Upload(zeroPoint, c.winBuffer, c.winBuffer.Bounds()) - c.windowControl.Scale(c.windowRect, c.windowTexture, c.windowTexture.Bounds(), draw.Src) - c.windowControl.Publish() +func (w *Window) publish() { + w.prePublish(w, w.windowTexture) + w.windowTexture.Upload(zeroPoint, w.winBuffer, w.winBuffer.Bounds()) + w.windowControl.Scale(w.windowRect, w.windowTexture, w.windowTexture.Bounds(), draw.Src) + w.windowControl.Publish() +} + +// DoBetweenDraws will execute the given function in-between draw frames +func (w *Window) DoBetweenDraws(f func()) { + go func() { + w.betweenDrawCh <- f + }() } diff --git a/drawLoop_test.go b/drawLoop_test.go deleted file mode 100644 index 05a168d2..00000000 --- a/drawLoop_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package oak - -import ( - "sync" - "testing" - "time" - - "github.com/oakmound/oak/v3/scene" -) - -var once sync.Once - -func BenchmarkDrawLoop(b *testing.B) { - c1 := NewController() - c1.AddScene("draw", scene.Scene{}) - go c1.Init("draw") - // give the engine some time to start - time.Sleep(5 * time.Second) - // We don't want any regular ticks getting through - c1.DrawTicker.SetTick(100 * time.Hour) - - b.ResetTimer() - // This sees how fast the draw ticker will accept forced steps, - // which won't be accepted until the draw loop itself pulls - // from the draw ticker, which it only does after having drawn - // the screen for a frame. This way we push the draw loop - // to draw as fast as possible and measure that speed. - for i := 0; i < b.N; i++ { - c1.DrawTicker.ForceStep() - } -} diff --git a/entities/doodad.go b/entities/doodad.go index eb97b380..47c4beff 100644 --- a/entities/doodad.go +++ b/entities/doodad.go @@ -1,8 +1,6 @@ package entities import ( - "strconv" - "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/render" ) @@ -79,12 +77,3 @@ func (d *Doodad) SetPos(x, y float64) { d.R.SetPos(x, y) } } - -func (d *Doodad) String() string { - s := "Doodad: \nP{ " - s += d.Point.String() - s += " }\nID:{ " - s += strconv.Itoa(int(d.CID)) - s += " }" - return s -} diff --git a/entities/point.go b/entities/point.go index f937cae5..ec4a900e 100644 --- a/entities/point.go +++ b/entities/point.go @@ -1,8 +1,6 @@ package entities import ( - "strconv" - "github.com/oakmound/oak/v3/physics" ) @@ -42,9 +40,3 @@ func (p *Point) DistanceTo(x, y float64) float64 { func (p *Point) DistanceToPoint(p2 Point) float64 { return p.Distance(p2.Vector) } - -func (p *Point) String() string { - x := strconv.FormatFloat(p.X(), 'f', 2, 32) - y := strconv.FormatFloat(p.Y(), 'f', 2, 32) - return "X(): " + x + ", Y(): " + y -} diff --git a/entities/reactive.go b/entities/reactive.go index 403545b1..4acfa644 100644 --- a/entities/reactive.go +++ b/entities/reactive.go @@ -1,8 +1,6 @@ package entities import ( - "strconv" - "github.com/oakmound/oak/v3/collision" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/render" @@ -25,10 +23,11 @@ func NewReactive(x, y, w, h float64, r render.Renderable, tree *collision.Tree, rct.Doodad = *NewDoodad(x, y, r, cid) rct.W = w rct.H = h - rct.RSpace = collision.NewEmptyReactiveSpace(collision.NewSpace(x, y, w, h, cid)) + rct.RSpace = collision.NewReactiveSpace(collision.NewSpace(x, y, w, h, cid), map[collision.Label]collision.OnHit{}) if tree == nil { tree = collision.DefaultTree } + rct.RSpace.Tree = tree rct.Tree = tree rct.Tree.Add(rct.RSpace.Space) return &rct @@ -99,16 +98,3 @@ func (r *Reactive) Destroy() { r.Tree.Remove(r.RSpace.Space) r.Doodad.Destroy() } - -func (r *Reactive) String() string { - st := "Reactive:\n{" - st += r.Doodad.String() - st += " }, \n" - w := strconv.FormatFloat(r.W, 'f', 2, 32) - h := strconv.FormatFloat(r.H, 'f', 2, 32) - st += "W: " + w + ", H: " + h - st += ",\nS:{ " - st += r.RSpace.String() - st += "}" - return st -} diff --git a/entities/solid.go b/entities/solid.go index 51df178f..01b7e0d2 100644 --- a/entities/solid.go +++ b/entities/solid.go @@ -1,8 +1,6 @@ package entities import ( - "strconv" - "github.com/oakmound/oak/v3/collision" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/render" @@ -122,16 +120,3 @@ func (s *Solid) Destroy() { s.Doodad.Destroy() s.Tree.Remove(s.Space) } - -func (s *Solid) String() string { - st := "Solid:\n{" - st += s.Doodad.String() - st += "},\n" - w := strconv.FormatFloat(s.W, 'f', 2, 32) - h := strconv.FormatFloat(s.H, 'f', 2, 32) - st += "W: " + w + ", H: " + h - st += ",\nS:{" - st += s.Space.String() - st += "}" - return st -} diff --git a/entities/x/btn/box.go b/entities/x/btn/box.go index 29312db4..54b3be7b 100644 --- a/entities/x/btn/box.go +++ b/entities/x/btn/box.go @@ -14,7 +14,7 @@ type Box struct { metadata map[string]string } -// NewBox creates a new btn.box +// NewBox creates a new Box func NewBox(cid event.CID, x, y, w, h float64, r render.Renderable, layers ...int) *Box { b := Box{} cid = cid.Parse(&b) @@ -26,7 +26,7 @@ func NewBox(cid event.CID, x, y, w, h float64, r render.Renderable, layers ...in return &b } -// Init intializes the btn.box +// Init intializes the Box func (b *Box) Init() event.CID { b.CID = event.NextID(b) return b.CID @@ -52,3 +52,9 @@ func (b *Box) Metadata(k string) (v string, ok bool) { v, ok = b.metadata[k] return v, ok } + +func (b *Box) Destroy() { + b.UnbindAll() + b.R.Undraw() + mouse.Remove(b.GetSpace()) +} \ No newline at end of file diff --git a/entities/x/btn/btn.go b/entities/x/btn/btn.go index 9175b638..85a8fbcb 100644 --- a/entities/x/btn/btn.go +++ b/entities/x/btn/btn.go @@ -14,4 +14,5 @@ type Btn interface { GetSpace() *collision.Space SetMetadata(string, string) Metadata(string) (string, bool) + Destroy() } diff --git a/entities/x/btn/button.go b/entities/x/btn/button.go index efe23617..132de378 100644 --- a/entities/x/btn/button.go +++ b/entities/x/btn/button.go @@ -17,32 +17,32 @@ import ( // A Generator defines the variables used to create buttons from optional arguments type Generator struct { - X, Y float64 - W, H float64 - TxtX, TxtY float64 - Color color.Color - Color2 color.Color - ProgressFunc func(x, y, w, h int) float64 - Mod mod.Transform - R render.Modifiable - R1 render.Modifiable - R2 render.Modifiable - RS []render.Modifiable - Cid event.CID - Font *render.Font - Layers []int - Text string - TextPtr *string - TextStringer fmt.Stringer - Children []Generator - Bindings map[string][]event.Bindable - Trigger string - Toggle *bool - ListChoice *int - Group *Group - DisallowRevert bool - Shape shape.Shape - Label collision.Label + X, Y float64 + W, H float64 + TxtX, TxtY float64 + Color color.Color + Color2 color.Color + ProgressFunc func(x, y, w, h int) float64 + Mod mod.Transform + R render.Modifiable + R1 render.Modifiable + R2 render.Modifiable + RS []render.Modifiable + Cid event.CID + Font *render.Font + Layers []int + Text string + TextPtr *string + TextStringer fmt.Stringer + Children []Generator + Bindings map[string][]event.Bindable + Trigger string + Toggle *bool + ListChoice *int + Group *Group + AllowRevert bool + Shape shape.Shape + Label collision.Label } func defGenerator() Generator { @@ -126,7 +126,7 @@ func (g Generator) generate(parent *Generator) Btn { } } - if !g.DisallowRevert { + if g.AllowRevert { box = render.NewReverting(box) } @@ -186,7 +186,7 @@ func (g Generator) generate(parent *Generator) Btn { g.Bindings[k] = []event.Bindable{ func(id event.CID, button interface{}) int { btn := id.E().(Btn) - mEvent, ok := button.(mouse.Event) + mEvent, ok := button.(*mouse.Event) // If the passed event is not a mouse event dont filter on location. // Main current use case is for nil events passed via simulated clicks. if !ok { @@ -271,7 +271,7 @@ func listFxn(g Generator) func(id event.CID, button interface{}) int { return func(id event.CID, button interface{}) int { btn := event.GetEntity(id).(Btn) i := *g.ListChoice - mEvent := button.(mouse.Event) + mEvent := button.(*mouse.Event) if mEvent.Button == mouse.ButtonLeft { i++ diff --git a/entities/x/btn/option.go b/entities/x/btn/option.go index f2a94dae..b5b1b3cd 100644 --- a/entities/x/btn/option.go +++ b/entities/x/btn/option.go @@ -163,10 +163,11 @@ func Click(bnd event.Bindable) Option { return Binding(mouse.ClickOn, bnd) } -// Todo: change this to AllowRevert, and reverse the default behavior -func DisallowRevert() Option { +// AllowRevert wraps a button in a Reverting renderable, enabling phase changes +// through modifications and reversion +func AllowRevert() Option { return func(g Generator) Generator { - g.DisallowRevert = true + g.AllowRevert = true return g } } diff --git a/entities/x/btn/text.go b/entities/x/btn/text.go index 65ecdc30..c882eff9 100644 --- a/entities/x/btn/text.go +++ b/entities/x/btn/text.go @@ -10,7 +10,7 @@ import ( // NewText creates some uitext func NewText(f *render.Font, str string, x, y float64, layers ...int) *entities.Doodad { - d := entities.NewDoodad(x, y, f.NewStrText(str, x, y), 0) + d := entities.NewDoodad(x, y, f.NewText(str, x, y), 0) render.Draw(d.R, layers...) return d } @@ -24,7 +24,7 @@ func NewIntText(f *render.Font, str *int, x, y float64, layers ...int) *entities // NewRawText creates some uitext from a stringer func NewRawText(f *render.Font, str fmt.Stringer, x, y float64, layers ...int) *entities.Doodad { - d := entities.NewDoodad(x, y, f.NewText(str, x, y), 0) + d := entities.NewDoodad(x, y, f.NewStringerText(str, x, y), 0) render.Draw(d.R, layers...) return d } diff --git a/entities/x/btn/textBox.go b/entities/x/btn/textBox.go index 6e46ae60..336d87ab 100644 --- a/entities/x/btn/textBox.go +++ b/entities/x/btn/textBox.go @@ -32,7 +32,7 @@ func NewTextBox(cid event.CID, x, y, w, h, txtX, txtY float64, cid = cid.Parse(b) b.Box = *NewBox(cid, x, y, w, h, r, layers...) - b.Text = f.NewStrText("Init", 0, 0) + b.Text = f.NewText("Init", 0, 0) b.Text.Vector = b.Text.Attach(b.Box.Vector, txtX, txtY) // We dont want to modify the input's layers but we do want the text to show up on top of the base renderable. @@ -81,3 +81,10 @@ func (b *TextBox) SetPos(x, y float64) { func (b *TextBox) SetOffsets(txtX, txtY float64) { b.Text.Vector = b.Text.Attach(b.Box.Vector, txtX, -txtY+b.H) } + +func (b *TextBox) Destroy() { + if b.Text != nil { + b.Text.Undraw() + } + b.Box.Destroy() +} \ No newline at end of file diff --git a/entities/x/btn/textOptions.go b/entities/x/btn/textOptions.go index 32a948d0..e48e2683 100644 --- a/entities/x/btn/textOptions.go +++ b/entities/x/btn/textOptions.go @@ -59,8 +59,12 @@ func TxtOff(x, y float64) Option { // be large enough for the given text plus the provided buffer func FitText(buffer int) Option { return func(g Generator) Generator { - if g.Font != nil && g.Text != "" { - w := g.Font.MeasureString(g.Text) + if g.Font != nil && (g.Text != "" || g.TextPtr != nil) { + measure := g.Text + if g.TextPtr != nil { + measure = *g.TextPtr + } + w := g.Font.MeasureString(measure) wf := float64(w.Ceil() + buffer) if g.W < wf { g.W = wf diff --git a/entities/x/force/hurtBox.go b/entities/x/force/hurtBox.go index 725861d7..12af2bc7 100644 --- a/entities/x/force/hurtBox.go +++ b/entities/x/force/hurtBox.go @@ -7,7 +7,7 @@ import ( "github.com/oakmound/oak/v3/collision" "github.com/oakmound/oak/v3/physics" "github.com/oakmound/oak/v3/render" - "github.com/oakmound/oak/v3/timing" + "github.com/oakmound/oak/v3/scene" ) type hurtBox struct { @@ -16,28 +16,28 @@ type hurtBox struct { // NewHurtBox creates a temporary collision space with a given force it should // apply to objects it collides with -func NewHurtBox(x, y, w, h float64, duration time.Duration, l collision.Label, fv physics.ForceVector) { +func NewHurtBox(ctx *scene.Context, x, y, w, h float64, duration time.Duration, l collision.Label, fv physics.ForceVector) { hb := new(hurtBox) hb.DirectionSpace = NewDirectionSpace(collision.NewLabeledSpace(x, y, w, h, l), fv) collision.Add(hb.Space) - go timing.DoAfter(duration, func() { + go ctx.DoAfter(duration, func() { collision.Remove(hb.Space) }) } // NewHurtColor creates a temporary collision space with a given force it should // apply to objects it collides with. The box is rendered as the given color -func NewHurtColor(x, y, w, h float64, duration time.Duration, l collision.Label, +func NewHurtColor(ctx *scene.Context, x, y, w, h float64, duration time.Duration, l collision.Label, fv physics.ForceVector, c color.Color, layers ...int) { cb := render.NewColorBox(int(w), int(h), c) - NewHurtDisplay(x, y, w, h, duration, l, fv, cb, layers...) + NewHurtDisplay(ctx, x, y, w, h, duration, l, fv, cb, layers...) } // NewHurtDisplay creates a temporary collision space with a given force it should // apply to objects it collides with. The box is rendered as the given renderable. // The input renderable is not copied before it is drawn. -func NewHurtDisplay(x, y, w, h float64, duration time.Duration, l collision.Label, +func NewHurtDisplay(ctx *scene.Context, x, y, w, h float64, duration time.Duration, l collision.Label, fv physics.ForceVector, r render.Renderable, layers ...int) { hb := new(hurtBox) @@ -45,7 +45,7 @@ func NewHurtDisplay(x, y, w, h float64, duration time.Duration, l collision.Labe collision.Add(hb.Space) r.SetPos(x, y) render.Draw(r, layers...) - go timing.DoAfter(duration, func() { + go ctx.DoAfter(duration, func() { collision.Remove(hb.Space) r.Undraw() }) diff --git a/entities/x/shake/shake.go b/entities/x/shake/shake.go new file mode 100644 index 00000000..e50007fc --- /dev/null +++ b/entities/x/shake/shake.go @@ -0,0 +1,122 @@ +package shake + +import ( + "context" + "math/rand" + "time" + + "github.com/oakmound/oak/v3/alg/floatgeom" + "github.com/oakmound/oak/v3/scene" + "github.com/oakmound/oak/v3/window" +) + +// A Shaker knows how to shake something by a (or up to a) given magnitude. +// If Random is true, the Shaker will shake up to the (negative or positive) +// magnitude of each the X and Y axes. Otherwise, it will oscillate between +// negative magnitude and positive magnitude. +type Shaker struct { + Magnitude floatgeom.Point2 + Delay time.Duration + Random bool + // ResetPosition determines whether the shaken entity will be reset back to its original position + // after shaking is complete. True by default. + ResetPosition bool +} + +var ( + // DefaultShaker is the global default shaker, used when ShakeScreen is called. + DefaultShaker = &Shaker{ + Random: false, + Magnitude: floatgeom.Point2{3.0, 3.0}, + Delay: 30 * time.Millisecond, + ResetPosition: true, + } +) + +type ShiftPoser interface { + ShiftPos(x, y float64) + SetPos(x, y float64) +} + +func Shake(sp ShiftPoser, dur time.Duration) { + DefaultShaker.Shake(sp, dur) +} + +func (sk *Shaker) Shake(sp ShiftPoser, dur time.Duration) { + sk.ShakeContext(context.Background(), sp, dur) +} + +func (sk *Shaker) ShakeContext(ctx context.Context, sp ShiftPoser, dur time.Duration) { + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(dur)) + mag := sk.Magnitude + delta := floatgeom.Point2{} + + if sk.Random { + randOff := mag + go func() { + defer cancel() + tick := time.NewTicker(sk.Delay) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + if sk.ResetPosition { + sp.ShiftPos(-delta.X(), -delta.Y()) + } + return + case <-tick.C: + } + xDelta := randOff.X() - delta.X() + yDelta := randOff.Y() - delta.Y() + sp.ShiftPos(xDelta, yDelta) + delta = delta.Add(floatgeom.Point2{xDelta, yDelta}) + mag = mag.MulConst(-1) + randOff = mag.MulConst(rand.Float64()) + } + + }() + } else { + go func() { + defer cancel() + tick := time.NewTicker(sk.Delay) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + if sk.ResetPosition { + sp.ShiftPos(-delta.X(), -delta.Y()) + } + return + case <-tick.C: + } + xDelta := mag.X() + yDelta := mag.Y() + + sp.ShiftPos(xDelta, yDelta) + delta = delta.Add(floatgeom.Point2{xDelta, yDelta}) + mag = mag.MulConst(-1) + } + }() + } +} + +type screenToPoser struct { + window.Window +} + +func (stp screenToPoser) ShiftPos(x, y float64) { + stp.ShiftScreen(int(x), int(y)) +} + +func (stp screenToPoser) SetPos(x, y float64) { + stp.SetScreen(int(x), int(y)) +} + +func ShakeScreen(ctx *scene.Context, dur time.Duration) { + DefaultShaker.ShakeScreen(ctx, dur) +} + +func (sk *Shaker) ShakeScreen(ctx *scene.Context, dur time.Duration) { + poser := screenToPoser{ctx.Window} + sk.ShakeContext(ctx, poser, dur) +} diff --git a/event/bind.go b/event/bind.go index eed49ff9..d8040442 100644 --- a/event/bind.go +++ b/event/bind.go @@ -1,14 +1,9 @@ package event -import "github.com/oakmound/oak/v3/dlog" - // Bind adds a function to the event bus tied to the given callerID // to be called when the event name is triggered. It is equivalent to // calling BindPriority with a zero Priority. func (eb *Bus) Bind(name string, callerID CID, fn Bindable) { - - dlog.Verb("Binding ", callerID, " with name ", name) - eb.pendingMutex.Lock() eb.binds = append(eb.binds, UnbindOption{ Event: Event{ diff --git a/event/bus.go b/event/bus.go index 7faf05a6..9a1920bd 100644 --- a/event/bus.go +++ b/event/bus.go @@ -4,8 +4,6 @@ import ( "reflect" "sync" "time" - - "github.com/oakmound/oak/v3/timing" ) // Bindable is a way of saying "Any function @@ -34,7 +32,7 @@ type Bus struct { doneCh chan struct{} updateCh chan struct{} framesElapsed int - Ticker *timing.DynamicTicker + Ticker *time.Ticker binds []UnbindOption partUnbinds []Event fullUnbinds []UnbindOption @@ -42,6 +40,7 @@ type Bus struct { unbindAllAndRebinds []UnbindAllOption framerate int refreshRate time.Duration + callerMap *CallerMap mutex sync.RWMutex pendingMutex sync.Mutex @@ -49,21 +48,17 @@ type Bus struct { init sync.Once } -// NewBus returns an empty event bus -func NewBus() *Bus { +// NewBus returns an empty event bus with an assigned caller map. If nil +// is provided, the caller map used will be DefaultCallerMap +func NewBus(callerMap *CallerMap) *Bus { + if callerMap == nil { + callerMap = DefaultCallerMap + } return &Bus{ - Ticker: timing.NewDynamicTicker(), - bindingMap: make(map[string]map[CID]*bindableList), - updateCh: make(chan struct{}), - doneCh: make(chan struct{}), - binds: make([]UnbindOption, 0), - partUnbinds: make([]Event, 0), - fullUnbinds: make([]UnbindOption, 0), - unbinds: make([]binding, 0), - unbindAllAndRebinds: make([]UnbindAllOption, 0), - mutex: sync.RWMutex{}, - pendingMutex: sync.Mutex{}, - init: sync.Once{}, + bindingMap: make(map[string]map[CID]*bindableList), + updateCh: make(chan struct{}), + doneCh: make(chan struct{}), + callerMap: callerMap, } } diff --git a/event/bus_test.go b/event/bus_test.go index 56e4d7a1..20a33d1c 100644 --- a/event/bus_test.go +++ b/event/bus_test.go @@ -3,10 +3,12 @@ package event import ( "fmt" "testing" + "time" ) func TestBusStop(t *testing.T) { - b := NewBus() + b := NewBus(nil) + b.Ticker = time.NewTicker(10000 * time.Second) phase := 0 wait := make(chan struct{}) var topErr error diff --git a/event/callerMap.go b/event/callerMap.go index 37a7821f..e89bc1da 100644 --- a/event/callerMap.go +++ b/event/callerMap.go @@ -98,5 +98,5 @@ func DestroyEntity(id CID) { // ResetCallerMap resets the DefaultCallerMap to be empty. func ResetCallerMap() { - DefaultCallerMap = NewCallerMap() + *DefaultCallerMap = *NewCallerMap() } diff --git a/event/cid.go b/event/cid.go index e57ee1e0..14a2946b 100644 --- a/event/cid.go +++ b/event/cid.go @@ -4,8 +4,6 @@ package event type CID int // E is shorthand for GetEntity(int(cid)) -// But we apparently forgot we added this shorthand, -// because this isn't used anywhere. func (cid CID) E() interface{} { return GetEntity(cid) } diff --git a/event/default.go b/event/default.go index d9fd7414..9fecbfaf 100644 --- a/event/default.go +++ b/event/default.go @@ -6,12 +6,11 @@ package event var ( // DefaultBus is a bus that has additional operations for CIDs, and can // be called via event.Call as opposed to bus.Call - DefaultBus = NewBus() + DefaultBus = NewBus(DefaultCallerMap) ) // Trigger an event, but only for one ID, on the default bus func (cid CID) Trigger(eventName string, data interface{}) { - go func(eventName string, data interface{}) { DefaultBus.mutex.RLock() if idMap, ok := DefaultBus.bindingMap[eventName]; ok { @@ -23,6 +22,10 @@ func (cid CID) Trigger(eventName string, data interface{}) { }(eventName, data) } +func (cid CID) TriggerBus(eventName string, data interface{}, bus Handler) chan struct{} { + return bus.TriggerCIDBack(cid, eventName, data) +} + // Bind on a CID is shorthand for bus.Bind(name, cid, fn), on the default bus. func (cid CID) Bind(name string, fn Bindable) { DefaultBus.Bind(name, cid, fn) @@ -94,9 +97,9 @@ func Reset() { DefaultBus.Reset() } -// ResolvePending calls ResolvePending on the DefaultBus -func ResolvePending() { - DefaultBus.ResolvePending() +// ResolveChanges calls ResolveChanges on the DefaultBus +func ResolveChanges() { + DefaultBus.ResolveChanges() } // SetTick calls SetTick on the DefaultBus diff --git a/event/event_test.go b/event/event_test.go index ec385e1d..1f689c98 100644 --- a/event/event_test.go +++ b/event/event_test.go @@ -17,7 +17,7 @@ func sleep() { func TestBus(t *testing.T) { triggers := 0 - go ResolvePending() + go ResolveChanges() GlobalBind("T", Empty(func() { triggers++ })) @@ -35,7 +35,7 @@ func TestBus(t *testing.T) { func TestUnbind(t *testing.T) { triggers := 0 - go ResolvePending() + go ResolveChanges() GlobalBind("T", func(CID, interface{}) int { triggers++ return UnbindSingle @@ -100,7 +100,7 @@ func (e ent) Init() CID { func TestCID(t *testing.T) { triggers := 0 - go ResolvePending() + go ResolveChanges() cid := CID(0).Parse(ent{}) cid.Bind("T", func(CID, interface{}) int { triggers++ @@ -152,7 +152,7 @@ func TestCID(t *testing.T) { } func TestEntity(t *testing.T) { - go ResolvePending() + go ResolveChanges() e := ent{} cid := e.Init() cid2 := cid.Parse(e) @@ -173,7 +173,7 @@ var ( ) func TestUnbindBindable(t *testing.T) { - go ResolvePending() + go ResolveChanges() GlobalBind("T", tBinding) sleep() Trigger("T", nil) @@ -214,7 +214,7 @@ func TestBindableList(t *testing.T) { } func TestUnbindAllAndRebind(t *testing.T) { - go ResolvePending() + go ResolveChanges() UnbindAllAndRebind( Event{ Name: "T", diff --git a/event/handler.go b/event/handler.go index 1fcd4535..81d7f37f 100644 --- a/event/handler.go +++ b/event/handler.go @@ -29,6 +29,7 @@ type Handler interface { // Trigger(event string, data interface{}) TriggerBack(event string, data interface{}) chan struct{} + TriggerCIDBack(cid CID, eventName string, data interface{}) chan struct{} // Pause() Resume() @@ -60,14 +61,25 @@ func (eb *Bus) UpdateLoop(framerate int, updateCh chan struct{}) error { eb.doneCh = ch eb.updateCh = updateCh eb.framerate = framerate - eb.Ticker = timing.NewDynamicTicker() - go eb.ResolvePending() + frameDelay := timing.FPSToFrameDelay(framerate) + if eb.Ticker == nil { + eb.Ticker = time.NewTicker(frameDelay) + } + go eb.ResolveChanges() go func(doneCh chan struct{}) { - eb.Ticker.SetTick(timing.FPSToFrameDelay(framerate)) + eb.Ticker.Reset(frameDelay) + frameDelayF64 := float64(frameDelay) + lastTick := time.Now() for { select { - case <-eb.Ticker.C: - <-eb.TriggerBack(Enter, eb.framesElapsed) + case now := <-eb.Ticker.C: + deltaTime := now.Sub(lastTick) + lastTick = now + <-eb.TriggerBack(Enter, EnterPayload{ + FramesElapsed: eb.framesElapsed, + SinceLastFrame: deltaTime, + TickPercent: float64(deltaTime) / frameDelayF64, + }) eb.framesElapsed++ eb.updateCh <- struct{}{} case <-doneCh: @@ -80,9 +92,17 @@ func (eb *Bus) UpdateLoop(framerate int, updateCh chan struct{}) error { return nil } +type EnterPayload struct { + FramesElapsed int + SinceLastFrame time.Duration + TickPercent float64 +} + // Update updates all entities bound to this handler func (eb *Bus) Update() error { - <-eb.TriggerBack(Enter, eb.framesElapsed) + <-eb.TriggerBack(Enter, EnterPayload{ + FramesElapsed: eb.framesElapsed, + }) return nil } @@ -115,7 +135,9 @@ func (eb *Bus) Flush() error { // Stop ceases anything spawned by an ongoing UpdateLoop func (eb *Bus) Stop() error { - eb.Ticker.SetTick(math.MaxInt32 * time.Second) + if eb.Ticker != nil { + eb.Ticker.Reset(math.MaxInt32 * time.Second) + } select { case eb.doneCh <- struct{}{}: case <-eb.updateCh: @@ -127,12 +149,12 @@ func (eb *Bus) Stop() error { // Pause stops the event bus from running any further enter events func (eb *Bus) Pause() { - eb.Ticker.SetTick(math.MaxInt32 * time.Second) + eb.Ticker.Reset(math.MaxInt32 * time.Second) } // Resume will resume emitting enter events func (eb *Bus) Resume() { - eb.Ticker.SetTick(timing.FPSToFrameDelay(eb.framerate)) + eb.Ticker.Reset(timing.FPSToFrameDelay(eb.framerate)) } // FramesElapsed returns how many frames have elapsed since UpdateLoop was last called. @@ -144,6 +166,6 @@ func (eb *Bus) FramesElapsed() int { // (while it is looping) to be frameRate. If this operation is not // supported, it should return an error. func (eb *Bus) SetTick(framerate int) error { - eb.Ticker.SetTick(timing.FPSToFrameDelay(framerate)) + eb.Ticker.Reset(timing.FPSToFrameDelay(framerate)) return nil } diff --git a/event/handler_test.go b/event/handler_test.go index 93777826..9c5e0d1b 100644 --- a/event/handler_test.go +++ b/event/handler_test.go @@ -41,12 +41,13 @@ func TestHandler(t *testing.T) { t.Fatal("Handler should be closed") default: } + expectedTriggers := triggers + 1 if Update() != nil { t.Fatalf("Update failed") } sleep() - if triggers != 3 { + if triggers != expectedTriggers { t.Fatalf("expected update to increment triggers") } if Flush() != nil { @@ -66,7 +67,7 @@ func TestHandler(t *testing.T) { func BenchmarkHandler(b *testing.B) { triggers := 0 entities := 10 - go DefaultBus.ResolvePending() + go DefaultBus.ResolveChanges() for i := 0; i < entities; i++ { DefaultBus.GlobalBind(Enter, func(CID, interface{}) int { triggers++ @@ -80,8 +81,8 @@ func BenchmarkHandler(b *testing.B) { } func TestPauseAndResume(t *testing.T) { - b := NewBus() - b.ResolvePending() + b := NewBus(nil) + b.ResolveChanges() triggerCt := 0 b.Bind("EnterFrame", 0, func(CID, interface{}) int { triggerCt++ diff --git a/event/resolve.go b/event/resolve.go index b60b6f5a..847d6d82 100644 --- a/event/resolve.go +++ b/event/resolve.go @@ -2,13 +2,13 @@ package event import "time" -// ResolvePending is a constant loop that tracks slices of bind or unbind calls +// ResolveChanges is a constant loop that tracks slices of bind or unbind calls // and resolves them individually such that they don't break the bus. // Each section of the loop waits for the predetermined refreshrate prior to attempting to flush. -// Todo: this should have a better name +// // If you ask "Why does this not use select over channels, share memory by communicating", // the answer is we tried, and it was cripplingly slow. -func (eb *Bus) ResolvePending() { +func (eb *Bus) ResolveChanges() { eb.init.Do(func() { go func() { for { @@ -19,7 +19,7 @@ func (eb *Bus) ResolvePending() { }) } -// SetRefreshRate on the event bus detailing the time to wait per attempt to ResolvePending. +// SetRefreshRate on the event bus detailing the time to wait per attempt to ResolveChanges. func (eb *Bus) SetRefreshRate(refreshRate time.Duration) { eb.refreshRate = refreshRate } diff --git a/event/resolve_test.go b/event/resolve_test.go index 863d0913..58cbc97c 100644 --- a/event/resolve_test.go +++ b/event/resolve_test.go @@ -5,10 +5,10 @@ import ( "time" ) -func TestResolvePendingWithRefreshRate(t *testing.T) { - b := NewBus() +func TestResolveChangesWithRefreshRate(t *testing.T) { + b := NewBus(nil) b.SetRefreshRate(6 * time.Second) - b.ResolvePending() + b.ResolveChanges() failed := false b.Bind("EnterFrame", 0, func(CID, interface{}) int { failed = true diff --git a/event/strings.go b/event/strings.go index 4dce1354..0c4885fa 100644 --- a/event/strings.go +++ b/event/strings.go @@ -6,10 +6,10 @@ package event // Payload: (collision.Label) the label the entity has started/stopped touching // // - MouseCollisionStart/Stop: as above, for mouse collision -// Payload: (mouse.Event) +// Payload: (*mouse.Event) // // - Mouse events: MousePress, MouseRelease, MouseScrollDown, MouseScrollUp, MouseDrag -// Payload: (mouse.Event) details on the mouse event +// Payload: (*mouse.Event) details on the mouse event // // - KeyDown, KeyDown$a: when any key is pressed down, when key $a is pressed down. // Payload: (key.Event) the key pressed @@ -20,7 +20,7 @@ package event // And the following: const ( // Enter : the beginning of every logical frame. - // Payload: (int) frames passed since this scene started + // Payload: (EnterPayload) details on the frame and time since last tick Enter = "EnterFrame" // AnimationEnd: Triggered on animations CIDs when they loop from the last to the first frame // Payload: nil @@ -31,12 +31,15 @@ const ( // OnStop: Triggered when the engine is stopped. // Payload: nil OnStop = "OnStop" - // FocusGain: Triggered when the window gains focus + // FocusGain: Triggered when the window gains focus // Payload: nil FocusGain = "FocusGain" - // FocusLoss: Triggered when the window loses focus + // FocusLoss: Triggered when the window loses focus // Payload: nil FocusLoss = "FocusLoss" + // InputChange: triggered when the most recent input device changes (e.g. keyboard to joystick or vice versa) + // Payload: oak.InputType + InputChange = "InputChange" ) // diff --git a/event/trigger.go b/event/trigger.go index e93046aa..d2458c3d 100644 --- a/event/trigger.go +++ b/event/trigger.go @@ -20,13 +20,11 @@ import ( // TriggerBack is right now used by the primary logic loop to dictate logical // framerate, so EnterFrame events are called through TriggerBack. func (eb *Bus) TriggerBack(eventName string, data interface{}) chan struct{} { - ch := make(chan struct{}) go func(ch chan struct{}, eb *Bus, eventName string, data interface{}) { eb.trigger(eventName, data) close(ch) }(ch, eb, eventName, data) - return ch } @@ -38,6 +36,21 @@ func (eb *Bus) Trigger(eventName string, data interface{}) { }(eb, eventName, data) } +func (eb *Bus) TriggerCIDBack(cid CID, eventName string, data interface{}) chan struct{} { + ch := make(chan struct{}) + go func() { + eb.mutex.RLock() + if idMap, ok := eb.bindingMap[eventName]; ok { + if bs, ok := idMap[cid]; ok { + eb.triggerDefault(bs.sl, cid, eventName, data) + } + } + eb.mutex.RUnlock() + close(ch) + }() + return ch +} + func (eb *Bus) trigger(eventName string, data interface{}) { eb.mutex.RLock() for id, bs := range eb.bindingMap[eventName] { @@ -52,6 +65,10 @@ func (eb *Bus) triggerDefault(sl []Bindable, id CID, eventName string, data inte prog := &sync.WaitGroup{} prog.Add(len(sl)) for i, bnd := range sl { + if bnd == nil { + prog.Done() + continue + } go func(bnd Bindable, id CID, eventName string, data interface{}, prog *sync.WaitGroup, index int) { eb.handleBindable(bnd, id, data, index, eventName) prog.Done() @@ -61,24 +78,23 @@ func (eb *Bus) triggerDefault(sl []Bindable, id CID, eventName string, data inte } func (eb *Bus) handleBindable(bnd Bindable, id CID, data interface{}, index int, eventName string) { - if bnd != nil { - if id == 0 || GetEntity(id) != nil { - response := bnd(id, data) - switch response { - case UnbindEvent: - UnbindAll(Event{ + if id == 0 || eb.callerMap.HasEntity(id) { + response := bnd(id, data) + switch response { + case UnbindEvent: + UnbindAll(Event{ + Name: eventName, + CallerID: id, + }) + case UnbindSingle: + bnd := binding{ + Event: Event{ Name: eventName, CallerID: id, - }) - case UnbindSingle: - binding{ - Event{ - Name: eventName, - CallerID: id, - }, - index, - }.unbind(eb) + }, + index: index, } + bnd.unbind(eb) } } } diff --git a/examples/bezier/main.go b/examples/bezier/main.go index 4b83aee7..21ce0abf 100644 --- a/examples/bezier/main.go +++ b/examples/bezier/main.go @@ -6,6 +6,7 @@ import ( "strconv" oak "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/debugstream" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/mouse" "github.com/oakmound/oak/v3/render" @@ -34,9 +35,9 @@ func main() { // bezier X Y X Y X Y ... // for defining custom points without using the mouse. // does not interact with the mouse points tracked through left clicks. - oak.AddCommand("bezier", func(tokens []string) { + debugstream.AddCommand(debugstream.Command{Name: "bezier", Operation: func(tokens []string) string { if len(tokens) < 4 { - return + return "" } tokens = tokens[1:] var err error @@ -45,16 +46,17 @@ func main() { floats[i], err = strconv.ParseFloat(s, 64) if err != nil { fmt.Println(err) - return + return "" } } renderCurve(floats) - }) + return "" + }}) oak.AddScene("bezier", scene.Scene{Start: func(*scene.Context) { mouseFloats := []float64{} event.GlobalBind(mouse.Press, func(_ event.CID, mouseEvent interface{}) int { - me := mouseEvent.(mouse.Event) + me := mouseEvent.(*mouse.Event) // Left click to add a point to the curve if me.Button == mouse.ButtonLeft { mouseFloats = append(mouseFloats, float64(me.X()), float64(me.Y())) @@ -67,7 +69,10 @@ func main() { return 0 }) }}) - oak.Init("bezier") + oak.Init("bezier", func(c oak.Config) (oak.Config, error) { + c.EnableDebugConsole = true + return c, nil + }) } func bezierDraw(b shape.Bezier) *render.CompositeM { diff --git a/examples/click-propagation/main.go b/examples/click-propagation/main.go new file mode 100644 index 00000000..c56e6fc0 --- /dev/null +++ b/examples/click-propagation/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/draw" + + "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/collision" + "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/mouse" + "github.com/oakmound/oak/v3/render" + "github.com/oakmound/oak/v3/scene" +) + +// This example demonstrates the use of the Propagated boolean on +// mouse event payloads to prevent mouse interactions from falling +// through to lower UI elements after interacting with a higher layer + +func main() { + oak.AddScene("click-propagation", scene.Scene{ + Start: func(ctx *scene.Context) { + z := 0 + y := 400.0 + for x := 20.0; x < 400; x += 20 { + z++ + y -= 20 + newHoverButton(x, y, 35, 35, color.RGBA{200, 200, 200, 200}, z) + } + }, + }) + oak.Init("click-propagation") +} + +type hoverButton struct { + id event.CID + + mouse.CollisionPhase + *changingColorBox +} + +func (hb *hoverButton) Init() event.CID { + hb.id = event.NextID(hb) + return hb.id +} + +func newHoverButton(x, y, w, h float64, clr color.RGBA, layer int) { + hb := &hoverButton{} + hb.Init() + hb.changingColorBox = newChangingColorBox(x, y, int(w), int(h), clr) + + sp := collision.NewSpace(x, y, w, h, hb.id) + sp.SetZLayer(float64(layer)) + + mouse.Add(sp) + mouse.PhaseCollision(sp) + + render.Draw(hb.changingColorBox, 0, layer) + hb.id.Bind(mouse.ClickOn, func(c event.CID, i interface{}) int { + hb := event.GetEntity(c).(*hoverButton) + me := i.(*mouse.Event) + fmt.Println(c, me.Point2) + hb.changingColorBox.c = color.RGBA{128, 128, 128, 128} + me.StopPropagation = true + return 0 + }) + hb.id.Bind(mouse.Start, func(c event.CID, i interface{}) int { + fmt.Println("start") + hb := event.GetEntity(c).(*hoverButton) + me := i.(*mouse.Event) + hb.changingColorBox.c = color.RGBA{50, 50, 50, 50} + me.StopPropagation = true + return 0 + }) + hb.id.Bind(mouse.Stop, func(c event.CID, i interface{}) int { + fmt.Println("stop") + hb := event.GetEntity(c).(*hoverButton) + me := i.(*mouse.Event) + hb.changingColorBox.c = clr + me.StopPropagation = true + return 0 + }) +} + +type changingColorBox struct { + render.LayeredPoint + c color.RGBA + w, h int +} + +func newChangingColorBox(x, y float64, w, h int, c color.RGBA) *changingColorBox { + return &changingColorBox{ + LayeredPoint: render.NewLayeredPoint(x, y, 0), + c: c, + w: w, + h: h, + } +} + +func (ccb *changingColorBox) Draw(buff draw.Image, xOff, yOff float64) { + x := int(ccb.X() + xOff) + y := int(ccb.Y() + yOff) + rect := image.Rect(x, y, ccb.w+x, ccb.h+y) + draw.Draw(buff, rect, image.NewUniform(ccb.c), image.Point{int(ccb.X() + xOff), int(ccb.Y() + yOff)}, draw.Over) +} + +func (ccb *changingColorBox) GetDims() (int, int) { + return ccb.w, ccb.h +} diff --git a/examples/cliffracers/assets/images/raw/background.png b/examples/cliffracers/assets/images/raw/background.png deleted file mode 100644 index 228cc2d2..00000000 Binary files a/examples/cliffracers/assets/images/raw/background.png and /dev/null differ diff --git a/examples/cliffracers/assets/images/raw/cliffracer.png b/examples/cliffracers/assets/images/raw/cliffracer.png deleted file mode 100644 index cbf859b6..00000000 Binary files a/examples/cliffracers/assets/images/raw/cliffracer.png and /dev/null differ diff --git a/examples/cliffracers/example.PNG b/examples/cliffracers/example.PNG deleted file mode 100644 index f58b6814..00000000 Binary files a/examples/cliffracers/example.PNG and /dev/null differ diff --git a/examples/cliffracers/main.go b/examples/cliffracers/main.go deleted file mode 100644 index bb8d70ee..00000000 --- a/examples/cliffracers/main.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "image" - "image/color" - "math/rand" - "path/filepath" - "time" - - "github.com/oakmound/oak/v3/collision" - "github.com/oakmound/oak/v3/dlog" - "github.com/oakmound/oak/v3/entities" - "github.com/oakmound/oak/v3/event" - "github.com/oakmound/oak/v3/physics" - "github.com/oakmound/oak/v3/render" - "github.com/oakmound/oak/v3/scene" - - oak "github.com/oakmound/oak/v3" -) - -var ( - playerAlive = new(bool) -) - -const ( - LabelCliffracer = 1 -) - -type CliffRacer struct { - *entities.Moving -} - -func (cr *CliffRacer) Init() event.CID { - return event.NextID(cr) -} - -// NewCliffRacer creates a new cliffracer -func NewCliffRacer(y float64) *CliffRacer { - cr := new(CliffRacer) - sp, err := render.LoadSprite(filepath.Join("assets", "images"), filepath.Join("raw", "cliffracer.png")) - if err != nil { - dlog.Error(err) - return nil - } - cr.Moving = entities.NewMoving(640, y, 80, 80, sp, nil, cr.Init(), 0) - cr.Speed = physics.NewVector(rand.Float64()*10+3, rand.Float64()*4-2) - render.Draw(cr.R, 100) - cr.Space = collision.NewLabeledSpace(cr.X(), cr.Y(), 80, 80, LabelCliffracer) - collision.Add(cr.Space) - cr.CID.Bind(event.Enter, moveCliffRacer) - cr.CID.Bind("PlayerHit", func(id event.CID, nothing interface{}) int { - event.GetEntity(id).(*CliffRacer).Destroy() - return 0 - }) - return cr -} - -func moveCliffRacer(id event.CID, nothing interface{}) int { - cr := event.GetEntity(id).(*CliffRacer) - cr.ShiftX(-cr.Speed.X()) - cr.ShiftY(cr.Speed.Y()) - if cr.X() < -100 { - cr.Destroy() - } - return 0 -} - -// Player creates the player who dodges cliffracers -type Player struct { - *entities.Solid -} - -// Init sets up the Player -func (p *Player) Init() event.CID { - return event.NextID(p) -} - -// NewPlayer creates a new player -func NewPlayer(ctx *scene.Context) { - p := new(Player) - p.Solid = entities.NewSolid(50, 100, 10, 10, render.NewColorBox(10, 10, color.RGBA{255, 0, 0, 255}), nil, p.Init()) - render.Draw(p.R, 80) - collision.Add(p.Space) - p.CID.Bind(event.Enter, playerEnter(ctx)) -} - -func playerEnter(ctx *scene.Context) func(id event.CID, nothing interface{}) int { - return func(id event.CID, nothing interface{}) int { - p := event.GetEntity(id).(*Player) - if oak.IsDown("W") { - p.ShiftY(-5) - } else if oak.IsDown("S") { - p.ShiftY(5) - } - if oak.IsDown("A") { - p.ShiftX(-5) - } else if oak.IsDown("D") { - p.ShiftX(5) - } - if p.X() < 0 { - p.ShiftX(-1 * p.X()) - } else if p.X() > float64(ctx.Window.Width()-10) { - p.ShiftX(-1 * (p.X() - float64(ctx.Window.Width()-10))) - } - if p.Y() < 0 { - p.ShiftY(-1 * p.Y()) - } else if p.Y() > float64(ctx.Window.Height()-10) { - p.ShiftY(-1 * (p.Y() - float64(ctx.Window.Height()-10))) - } - - if collision.HitLabel(p.Space, LabelCliffracer) != nil { - *playerAlive = false - } - return 0 - } -} - -func main() { - oak.AddScene("cliffRacers", scene.Scene{Start: func(ctx *scene.Context) { - *playerAlive = true - bkg, err := render.LoadSprite(filepath.Join("assets", "images"), filepath.Join("raw", "background.png")) - if err != nil { - dlog.Error(err) - return - } - render.Draw(bkg, 1) - fnt := render.DefaultFont() - fnt.Color = image.Black - fnt.Size = 22 - fnt, _ = fnt.Generate() - text := fnt.NewStrText("Dodge the Cliff Racers!", 70.0, 70.0) - render.Draw(text, 60000) - NewPlayer(ctx) - waitrand := 5000.0 - iteration := 1 - exclamationPoints := "" - go func() { - for { - select { - case <-time.After(((time.Duration(rand.Intn(int(waitrand)))) * time.Millisecond) + 50*time.Millisecond): - NewCliffRacer(float64(rand.Intn(200) + 50)) - iteration++ - if iteration%10 == 0 && waitrand > 400 { - exclamationPoints += "!" - text.SetString("Next Level" + exclamationPoints) - waitrand *= .7 - } - case <-ctx.Done(): - return - } - } - }() - }, Loop: scene.BooleanLoop(playerAlive), - }) - oak.Init("cliffRacers") -} diff --git a/examples/clipboard/go.mod b/examples/clipboard/go.mod new file mode 100644 index 00000000..ab86ad0b --- /dev/null +++ b/examples/clipboard/go.mod @@ -0,0 +1,11 @@ +module github.com/oakmound/oak/examples/clipboard + +go 1.16 + +require ( + github.com/atotto/clipboard v0.1.4 + github.com/oakmound/oak/v3 v3.0.0-alpha.1 + golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 +) + +replace github.com/oakmound/oak/v3 => ../.. diff --git a/examples/clipboard/go.sum b/examples/clipboard/go.sum new file mode 100644 index 00000000..4e786734 --- /dev/null +++ b/examples/clipboard/go.sum @@ -0,0 +1,61 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 h1:+PdD6GLKejR9DizMAKT5DpSAkKswvZrurk1/eEt9+pw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/disintegration/gift v1.2.0 h1:VMQeei2F+ZtsHjMgP6Sdt1kFjRhs2lGz8ljEOPeIR50= +github.com/disintegration/gift v1.2.0/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d/go.mod h1:CHkHWWZ4kbGY6jEy1+qlitDaCtRgNvCOQdakj/1Yl/Q= +github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1/go.mod h1:frG94byMNy+1CgGrQ25dZ+17tf98EN+OYBQL4Zh612M= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb h1:T6gaWBvRzJjuOrdCtg8fXXjKai2xSDqWTcKFUPuw8Tw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/hajimehoshi/go-mp3 v0.3.1 h1:pn/SKU1+/rfK8KaZXdGEC2G/KCB2aLRjbTCrwKcokao= +github.com/hajimehoshi/go-mp3 v0.3.1/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/oakmound/libudev v0.2.1 h1:gaXuw7Pbt3RSRxbUakAjl0dSW6Wo3TZWpwS5aMq8+EA= +github.com/oakmound/libudev v0.2.1/go.mod h1:zYF5CkHY+UP6lzWbPR+XoVAscl/s+OncWA//qWjMLUs= +github.com/oakmound/w32 v2.1.0+incompatible h1:vIkC6eJVOaAnwTTOyiVCGh24GoryPRmcvWq3cekkG2U= +github.com/oakmound/w32 v2.1.0+incompatible/go.mod h1:lzloWlclSXIU4cDr67WF8qjFFDO8gHHBIk4Qqe90enQ= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf h1:od9gEl9UQ/QNHlgYlgsSaC5SZ+CGbvO2/PCIgserJc0= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf/go.mod h1:RBXkZ8n2vvtdJP6PO+TbU/N/DVuCDwUN53CU+C1pJOs= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 h1:I8PHpJWTMTJZVDoosy8aXslFGe7wvcUbol7fOrVy4Tc= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370/go.mod h1:CaowXBWOiSGWEpBBV8LoVnQTVPV4ycyviC9IBLj8dRw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 h1:h+GZ3ubjuWaQjGe8owMGcmMVCqs0xYJtRG5y2bpHaqU= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872 h1:cGjJzUd8RgBw428LXP65YXni0aiGNA4Bl+ls8SmLOm8= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/examples/clipboard/main.go b/examples/clipboard/main.go new file mode 100644 index 00000000..76fc8e5c --- /dev/null +++ b/examples/clipboard/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + + gokey "golang.org/x/mobile/event/key" + + "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/entities/x/btn" + "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/key" + "github.com/oakmound/oak/v3/render" + "github.com/oakmound/oak/v3/scene" + + "github.com/atotto/clipboard" +) + +func main() { + oak.AddScene("clipboard-test", scene.Scene{ + Start: func(ctx *scene.Context) { + newClipboardCopyText("click-me-to-copy", 20, 20) + newClipboardCopyText("click-to-copy-me-too", 20, 50) + newClipboardPaster("click-or-ctrl+v-to-paste-here", 20, 200) + }, + }) + oak.Init("clipboard-test") +} + +func newClipboardCopyText(text string, x, y float64) { + btn.New( + btn.Font(render.DefaultFont()), + btn.Text(text), + btn.Pos(x, y), + btn.Height(20), + btn.FitText(20), + btn.Click(func(event.CID, interface{}) int { + err := clipboard.WriteAll(text) + if err != nil { + fmt.Println(err) + } + return 0 + }), + ) +} + +func newClipboardPaster(placeholder string, x, y float64) { + textPtr := new(string) + *textPtr = placeholder + btn.New( + btn.Font(render.DefaultFont()), + btn.TextPtr(textPtr), + btn.Pos(x, y), + btn.Height(20), + btn.FitText(20), + btn.Binding(key.Down+key.V, func(_ event.CID, payload interface{}) int { + kv := payload.(key.Event) + if kv.Modifiers&gokey.ModControl == gokey.ModControl { + got, err := clipboard.ReadAll() + if err != nil { + fmt.Println(err) + return 0 + } + *textPtr = got + } + return 0 + }), + btn.Click(func(event.CID, interface{}) int { + got, err := clipboard.ReadAll() + if err != nil { + fmt.Println(err) + return 0 + } + *textPtr = got + return 0 + }), + ) +} diff --git a/examples/custom-cursor/main.go b/examples/custom-cursor/main.go index 0f77267c..863e0ae7 100644 --- a/examples/custom-cursor/main.go +++ b/examples/custom-cursor/main.go @@ -38,7 +38,7 @@ func main() { ctx.DrawStack.Draw(box) ctx.EventHandler.GlobalBind(mouse.Drag, func(_ event.CID, me interface{}) int { - mouseEvent := me.(mouse.Event) + mouseEvent := me.(*mouse.Event) box.SetPos(mouseEvent.X(), mouseEvent.Y()) return 0 }) diff --git a/examples/error-scene/main.go b/examples/error-scene/main.go index 6022add0..02de8666 100644 --- a/examples/error-scene/main.go +++ b/examples/error-scene/main.go @@ -7,16 +7,16 @@ import ( ) func main() { - controller := oak.NewController() + controller := oak.NewWindow() // If ErrorScene is set, the scene handler will // fall back to this error scene if it is told to // go to an unknown scene controller.ErrorScene = "error" controller.AddScene("typo", scene.Scene{Start: func(ctx *scene.Context) { - ctx.DrawStack.Draw(render.NewStrText("Real scene", 100, 100)) + ctx.DrawStack.Draw(render.NewText("Real scene", 100, 100)) }}) controller.AddScene("error", scene.Scene{Start: func(ctx *scene.Context) { - ctx.DrawStack.Draw(render.NewStrText("Error scene", 100, 100)) + ctx.DrawStack.Draw(render.NewText("Error scene", 100, 100)) }}) controller.Init("typpo") diff --git a/examples/fallback-font/go.mod b/examples/fallback-font/go.mod new file mode 100644 index 00000000..6ae55a2d --- /dev/null +++ b/examples/fallback-font/go.mod @@ -0,0 +1,10 @@ +module github.com/oakmound/oak/examples/fallback-font + +go 1.16 + +require ( + github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b + github.com/oakmound/oak/v3 v3.0.0-alpha.1 +) + +replace github.com/oakmound/oak/v3 => ../.. diff --git a/examples/fallback-font/go.sum b/examples/fallback-font/go.sum new file mode 100644 index 00000000..dbc5548d --- /dev/null +++ b/examples/fallback-font/go.sum @@ -0,0 +1,61 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 h1:+PdD6GLKejR9DizMAKT5DpSAkKswvZrurk1/eEt9+pw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= +github.com/disintegration/gift v1.2.0 h1:VMQeei2F+ZtsHjMgP6Sdt1kFjRhs2lGz8ljEOPeIR50= +github.com/disintegration/gift v1.2.0/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d/go.mod h1:CHkHWWZ4kbGY6jEy1+qlitDaCtRgNvCOQdakj/1Yl/Q= +github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1/go.mod h1:frG94byMNy+1CgGrQ25dZ+17tf98EN+OYBQL4Zh612M= +github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b h1:/wqXgpZNTP8qV1dPEApjJXlDQd5N/F9U/WEvy5SawUI= +github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb h1:T6gaWBvRzJjuOrdCtg8fXXjKai2xSDqWTcKFUPuw8Tw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/hajimehoshi/go-mp3 v0.3.1 h1:pn/SKU1+/rfK8KaZXdGEC2G/KCB2aLRjbTCrwKcokao= +github.com/hajimehoshi/go-mp3 v0.3.1/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/oakmound/libudev v0.2.1 h1:gaXuw7Pbt3RSRxbUakAjl0dSW6Wo3TZWpwS5aMq8+EA= +github.com/oakmound/libudev v0.2.1/go.mod h1:zYF5CkHY+UP6lzWbPR+XoVAscl/s+OncWA//qWjMLUs= +github.com/oakmound/w32 v2.1.0+incompatible h1:vIkC6eJVOaAnwTTOyiVCGh24GoryPRmcvWq3cekkG2U= +github.com/oakmound/w32 v2.1.0+incompatible/go.mod h1:lzloWlclSXIU4cDr67WF8qjFFDO8gHHBIk4Qqe90enQ= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf h1:od9gEl9UQ/QNHlgYlgsSaC5SZ+CGbvO2/PCIgserJc0= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf/go.mod h1:RBXkZ8n2vvtdJP6PO+TbU/N/DVuCDwUN53CU+C1pJOs= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 h1:I8PHpJWTMTJZVDoosy8aXslFGe7wvcUbol7fOrVy4Tc= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370/go.mod h1:CaowXBWOiSGWEpBBV8LoVnQTVPV4ycyviC9IBLj8dRw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 h1:h+GZ3ubjuWaQjGe8owMGcmMVCqs0xYJtRG5y2bpHaqU= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872 h1:cGjJzUd8RgBw428LXP65YXni0aiGNA4Bl+ls8SmLOm8= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/examples/fallback-font/main.go b/examples/fallback-font/main.go index f24e3745..cc1c5b89 100644 --- a/examples/fallback-font/main.go +++ b/examples/fallback-font/main.go @@ -68,7 +68,7 @@ func main() { y := 0.0 for _, str := range strs { - render.Draw(font.NewStrText(str, 10, y), 0) + render.Draw(font.NewText(str, 10, y), 0) y += fontHeight } }, diff --git a/examples/flappy-bird/main.go b/examples/flappy-bird/main.go index fa4512ec..bded6562 100644 --- a/examples/flappy-bird/main.go +++ b/examples/flappy-bird/main.go @@ -13,7 +13,6 @@ import ( "github.com/oakmound/oak/v3/key" "github.com/oakmound/oak/v3/render" "github.com/oakmound/oak/v3/scene" - "github.com/oakmound/oak/v3/timing" ) var ( @@ -32,7 +31,7 @@ const ( ) func main() { - oak.AddScene("bounce", scene.Scene{Start: func(*scene.Context) { + oak.AddScene("bounce", scene.Scene{Start: func(ctx *scene.Context) { render.Draw(render.NewDrawFPS(0.03, nil, 10, 10)) score = 0 @@ -42,9 +41,9 @@ func main() { var pillarLoop func() pillarLoop = func() { newPillarPair() - timing.DoAfter(time.Duration(pillarFreq.Poll()*float64(time.Second)), pillarLoop) + ctx.DoAfter(time.Duration(pillarFreq.Poll()*float64(time.Second)), pillarLoop) } - go timing.DoAfter(time.Duration(pillarFreq.Poll()*float64(time.Second)), pillarLoop) + go ctx.DoAfter(time.Duration(pillarFreq.Poll()*float64(time.Second)), pillarLoop) // 3. Make Score t := render.DefaultFont().NewIntText(&score, 200, 30) diff --git a/examples/joystick-viz/main.go b/examples/joystick-viz/main.go index b825dfb6..2d185d91 100644 --- a/examples/joystick-viz/main.go +++ b/examples/joystick-viz/main.go @@ -10,7 +10,6 @@ import ( "github.com/oakmound/oak/v3/render/mod" - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/render" "github.com/oakmound/oak/v3/alg/floatgeom" @@ -21,6 +20,9 @@ import ( "github.com/oakmound/oak/v3/scene" ) +// try fiddling with this value +const Deadzone = 4000 + type renderer struct { event.CID joy *joystick.Joystick @@ -57,11 +59,10 @@ var initialOffsets = map[string]floatgeom.Point2{ "RtTrigger": {240, 6}, } -func newRenderer(ctx *scene.Context, joy *joystick.Joystick) { +func newRenderer(ctx *scene.Context, joy *joystick.Joystick) error { outline, err := render.LoadSprite("", "controllerOutline.png") if err != nil { - dlog.Error(err) - return + return err } rend := &renderer{ joy: joy, @@ -115,7 +116,15 @@ func newRenderer(ctx *scene.Context, joy *joystick.Joystick) { rend.rStickCenter = floatgeom.Point2{rend.rs["RtStick"].X(), rend.rs["RtStick"].Y()} joy.Handler = rend - joy.Listen(nil) + opts := &joystick.ListenOptions{ + JoystickChanges: true, + StickChanges: true, + StickDeadzoneLX: Deadzone, + StickDeadzoneLY: Deadzone, + StickDeadzoneRX: Deadzone, + StickDeadzoneRY: Deadzone, + } + joy.Listen(opts) bts := []string{ "X", @@ -184,34 +193,73 @@ func newRenderer(ctx *scene.Context, joy *joystick.Joystick) { x = rend.rs[tgr].X() rend.rs[tgr].SetPos(x, rend.triggerY+float64(st.TriggerR/16)) + return 0 + }) + + rend.Bind(joystick.LtStickChange, func(id event.CID, state interface{}) int { + rend, ok := event.GetEntity(id).(*renderer) + if !ok { + return 0 + } + st, ok := state.(*joystick.State) + if !ok { + return 0 + } + pos := rend.lStickCenter pos = pos.Add(floatgeom.Point2{ float64(st.StickLX / 2048), -float64(st.StickLY / 2048), }) rend.rs["LtStick"].SetPos(pos.X(), pos.Y()) + return 0 + }) + + rend.Bind(joystick.RtStickChange, func(id event.CID, state interface{}) int { + rend, ok := event.GetEntity(id).(*renderer) + if !ok { + return 0 + } + st, ok := state.(*joystick.State) + if !ok { + return 0 + } - pos = rend.rStickCenter + pos := rend.rStickCenter pos = pos.Add(floatgeom.Point2{ float64(st.StickRX / 2048), -float64(st.StickRY / 2048), }) rend.rs["RtStick"].SetPos(pos.X(), pos.Y()) - return 0 }) + return nil } func main() { oak.AddScene("viz", scene.Scene{Start: func(ctx *scene.Context) { joystick.Init() + latestInput := new(string) + *latestInput = "Latest Input: None" + ctx.DrawStack.Draw(render.NewStrPtrText(latestInput, 10, 460), 4) + ctx.DrawStack.Draw(render.NewText("Space to Vibrate", 10, 440), 4) + ctx.EventHandler.GlobalBind(event.InputChange, func(_ event.CID, payload interface{}) int { + input := payload.(oak.InputType) + switch input { + case oak.InputJoystick: + *latestInput = "Latest Input: Joystick" + case oak.InputKeyboardMouse: + *latestInput = "Latest Input: Keyboard+Mouse" + } + return 0 + }) go func() { jCh, cancel := joystick.WaitForJoysticks(1 * time.Second) defer cancel() - for { - select { - case joy := <-jCh: - newRenderer(ctx, joy) + for joy := range jCh { + err := newRenderer(ctx, joy) + if err != nil { + fmt.Println(err) } } }() @@ -219,6 +267,7 @@ func main() { oak.Init("viz", func(c oak.Config) (oak.Config, error) { c.Assets.ImagePath = "." c.Assets.AssetPath = "." + c.TrackInputChanges = true return c, nil }) } diff --git a/examples/keyboard-test/main.go b/examples/keyboard-test/main.go index 59cd56e9..52fae470 100644 --- a/examples/keyboard-test/main.go +++ b/examples/keyboard-test/main.go @@ -15,7 +15,7 @@ var keys = map[rune]struct{}{} func main() { oak.AddScene("keyboard-test", scene.Scene{Start: func(*scene.Context) { - kRenderable := render.NewStrText("", 40, 40) + kRenderable := render.NewText("", 40, 40) render.Draw(kRenderable, 0) event.GlobalBind(key.Down, func(_ event.CID, k interface{}) int { kValue := k.(key.Event) diff --git a/examples/multi-window/main.go b/examples/multi-window/main.go index 9a2ed162..9c2e5e4f 100644 --- a/examples/multi-window/main.go +++ b/examples/multi-window/main.go @@ -12,21 +12,22 @@ import ( ) func main() { - c1 := oak.NewController() + c1 := oak.NewWindow() c1.DrawStack = render.NewDrawStack(render.NewDynamicHeap()) // Two windows cannot share the same logic handler - c1.SetLogicHandler(event.NewBus()) + c1.SetLogicHandler(event.NewBus(nil)) c1.FirstSceneInput = color.RGBA{255, 0, 0, 255} c1.AddScene("scene1", scene.Scene{ Start: func(ctx *scene.Context) { fmt.Println("Start scene 1") cb := render.NewColorBox(50, 50, ctx.SceneInput.(color.RGBA)) + render.UpdateDebugMap("r", cb) cb.SetPos(50, 50) ctx.DrawStack.Draw(cb, 0) dFPS := render.NewDrawFPS(0.1, nil, 600, 10) ctx.DrawStack.Draw(dFPS, 1) - ctx.EventHandler.GlobalBind(mouse.Press, mouse.Binding(func(_ event.CID, me mouse.Event) int { + ctx.EventHandler.GlobalBind(mouse.Press, mouse.Binding(func(_ event.CID, me *mouse.Event) int { cb.SetPos(me.X(), me.Y()) return 0 })) @@ -37,24 +38,26 @@ func main() { c.Debug.Level = "VERBOSE" c.DrawFrameRate = 1200 c.FrameRate = 60 + c.EnableDebugConsole = true return c, nil }) fmt.Println("scene 1 exited") }() - c2 := oak.NewController() + c2 := oak.NewWindow() c2.DrawStack = render.NewDrawStack(render.NewDynamicHeap()) - c2.SetLogicHandler(event.NewBus()) + c2.SetLogicHandler(event.NewBus(nil)) c2.FirstSceneInput = color.RGBA{0, 255, 0, 255} c2.AddScene("scene2", scene.Scene{ Start: func(ctx *scene.Context) { fmt.Println("Start scene 2") cb := render.NewColorBox(50, 50, ctx.SceneInput.(color.RGBA)) + render.UpdateDebugMap("g", cb) cb.SetPos(50, 50) ctx.DrawStack.Draw(cb, 0) dFPS := render.NewDrawFPS(0.1, nil, 600, 10) ctx.DrawStack.Draw(dFPS, 1) - ctx.EventHandler.GlobalBind(mouse.Press, mouse.Binding(func(_ event.CID, me mouse.Event) int { + ctx.EventHandler.GlobalBind(mouse.Press, mouse.Binding(func(_ event.CID, me *mouse.Event) int { cb.SetPos(me.X(), me.Y()) return 0 })) @@ -64,9 +67,10 @@ func main() { c.Debug.Level = "VERBOSE" c.DrawFrameRate = 1200 c.FrameRate = 60 + c.EnableDebugConsole = true return c, nil }) fmt.Println("scene 2 exited") - //oak.Init() => oak.NewController(render.GlobalDrawStack, dlog.DefaultLogger ...).Init() + //oak.Init() => oak.NewWindow(render.GlobalDrawStack, dlog.DefaultLogger ...).Init() } diff --git a/examples/particle-demo/main.go b/examples/particle-demo/main.go index 8f17aa81..d7f3b792 100644 --- a/examples/particle-demo/main.go +++ b/examples/particle-demo/main.go @@ -7,10 +7,13 @@ import ( "strconv" oak "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/alg" "github.com/oakmound/oak/v3/alg/range/floatrange" "github.com/oakmound/oak/v3/alg/range/intrange" + "github.com/oakmound/oak/v3/debugstream" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/mouse" + "github.com/oakmound/oak/v3/oakerr" "github.com/oakmound/oak/v3/physics" "github.com/oakmound/oak/v3/render" pt "github.com/oakmound/oak/v3/render/particle" @@ -52,196 +55,220 @@ func parseShape(args []string) shape.Shape { func main() { - oak.AddCommand("followMouse", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "followMouse", Operation: func(args []string) string { event.GlobalBind(event.Enter, func(event.CID, interface{}) int { // It'd be interesting to attach to the mouse position src.SetPos(float64(mouse.LastEvent.X()), float64(mouse.LastEvent.Y())) return 0 }) - }) + return "" + }}) - oak.AddCommand("shape", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "shape", Operation: func(args []string) string { if len(args) > 0 { sh := parseShape(args) if sh != nil { src.Generator.(pt.Shapeable).SetShape(sh) } } - }) + return "" + }}) - oak.AddCommand("size", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "size", Operation: func(args []string) string { f1, f2, two, err := parseInts(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.(pt.Sizeable).SetSize(intrange.NewConstant(f1)) } else { src.Generator.(pt.Sizeable).SetSize(intrange.NewLinear(f1, f2)) } - }) - oak.AddCommand("endsize", func(args []string) { + return "" + }}) + + debugstream.AddCommand(debugstream.Command{Name: "endsize", Operation: func(args []string) string { f1, f2, two, err := parseInts(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.(pt.Sizeable).SetEndSize(intrange.NewConstant(f1)) } else { src.Generator.(pt.Sizeable).SetEndSize(intrange.NewLinear(f1, f2)) } - }) + return "" + }}) - oak.AddCommand("count", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "count", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.GetBaseGenerator().NewPerFrame = floatrange.NewConstant(npf) } else { src.Generator.GetBaseGenerator().NewPerFrame = floatrange.NewLinear(npf, npf2) } - }) + return "" + }}) - oak.AddCommand("life", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "life", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.GetBaseGenerator().LifeSpan = floatrange.NewConstant(npf) } else { src.Generator.GetBaseGenerator().LifeSpan = floatrange.NewLinear(npf, npf2) } - }) + return "" + }}) - oak.AddCommand("rotation", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "rotation", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.GetBaseGenerator().Rotation = floatrange.NewConstant(npf) } else { src.Generator.GetBaseGenerator().Rotation = floatrange.NewLinear(npf, npf2) } - }) + return "" + }}) - oak.AddCommand("angle", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "angle", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { - src.Generator.GetBaseGenerator().Angle = floatrange.NewConstant(npf) + src.Generator.GetBaseGenerator().Angle = floatrange.NewConstant(npf * alg.DegToRad) } else { - src.Generator.GetBaseGenerator().Angle = floatrange.NewLinear(npf, npf2) + src.Generator.GetBaseGenerator().Angle = floatrange.NewLinear(npf*alg.DegToRad, npf2*alg.DegToRad) } - }) + return "" + }}) - oak.AddCommand("speed", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "speed", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { src.Generator.GetBaseGenerator().Speed = floatrange.NewConstant(npf) } else { src.Generator.GetBaseGenerator().Speed = floatrange.NewLinear(npf, npf2) } - }) + return "" + }}) - oak.AddCommand("spread", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "spread", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { - return + return oakerr.InsufficientInputs{AtLeast: 2, InputName: "speeds"}.Error() } src.Generator.GetBaseGenerator().Spread.SetPos(npf, npf2) - }) + return "" + }}) - oak.AddCommand("gravity", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "gravity", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { - return + return oakerr.InsufficientInputs{AtLeast: 2, InputName: "speeds"}.Error() } src.Generator.GetBaseGenerator().Gravity.SetPos(npf, npf2) - }) + return "" + }}) - oak.AddCommand("speeddecay", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "speeddecay", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { - return + return oakerr.InsufficientInputs{AtLeast: 2, InputName: "speeds"}.Error() } src.Generator.GetBaseGenerator().SpeedDecay.SetPos(npf, npf2) - }) + return "" + }}) - oak.AddCommand("pos", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "pos", Operation: func(args []string) string { npf, npf2, two, err := parseFloats(args) if err != nil { - return + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() } if !two { - return + return oakerr.InsufficientInputs{AtLeast: 2, InputName: "positions"}.Error() } src.Generator.SetPos(npf, npf2) - }) - oak.AddCommand("startcolor", func(args []string) { - if len(args) > 3 { - r, g, b, a, err := parseRGBA(args) - if err != nil { - return - } - startColor = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} - src.Generator.(pt.Colorable).SetStartColor(startColor, startColorRand) + return "" + }}) + + debugstream.AddCommand(debugstream.Command{Name: "startcolor", Operation: func(args []string) string { + if len(args) < 3 { + return oakerr.InsufficientInputs{AtLeast: 3, InputName: "colorvalues"}.Error() } - }) + r, g, b, a, err := parseRGBA(args) + if err != nil { + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() + } + startColor = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + src.Generator.(pt.Colorable).SetStartColor(startColor, startColorRand) + return "" + }}) - oak.AddCommand("startrand", func(args []string) { - if len(args) > 3 { - r, g, b, a, err := parseRGBA(args) - if err != nil { - return - } - startColorRand = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} - src.Generator.(pt.Colorable).SetStartColor(startColor, startColorRand) + debugstream.AddCommand(debugstream.Command{Name: "startrand", Operation: func(args []string) string { + if len(args) < 3 { + return oakerr.InsufficientInputs{AtLeast: 3, InputName: "colorvalues"}.Error() } - }) + r, g, b, a, err := parseRGBA(args) + if err != nil { + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() + } + startColorRand = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + src.Generator.(pt.Colorable).SetStartColor(startColor, startColorRand) + return "" + }}) - oak.AddCommand("endcolor", func(args []string) { - if len(args) > 3 { - r, g, b, a, err := parseRGBA(args) - if err != nil { - return - } - endColor = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} - src.Generator.(pt.Colorable).SetEndColor(endColor, endColorRand) + debugstream.AddCommand(debugstream.Command{Name: "endcolor", Operation: func(args []string) string { + if len(args) < 3 { + return oakerr.InsufficientInputs{AtLeast: 3, InputName: "colorvalues"}.Error() } - }) + r, g, b, a, err := parseRGBA(args) + if err != nil { + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() + } + endColor = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + src.Generator.(pt.Colorable).SetEndColor(endColor, endColorRand) + return "" + }}) - oak.AddCommand("endrand", func(args []string) { - if len(args) > 3 { - r, g, b, a, err := parseRGBA(args) - if err != nil { - return - } - endColorRand = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} - src.Generator.(pt.Colorable).SetEndColor(endColor, endColorRand) + debugstream.AddCommand(debugstream.Command{Name: "endrand", Operation: func(args []string) string { + if len(args) < 3 { + return oakerr.InsufficientInputs{AtLeast: 3, InputName: "colorvalues"}.Error() } - }) + r, g, b, a, err := parseRGBA(args) + if err != nil { + return oakerr.UnsupportedFormat{Format: err.Error()}.Error() + } + endColorRand = color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)} + src.Generator.(pt.Colorable).SetEndColor(endColor, endColorRand) + return "" + }}) oak.AddScene("demo", scene.Scene{Start: func(*scene.Context) { + render.Draw(render.NewDrawFPS(0, nil, 10, 10)) x := 320.0 y := 240.0 newPf := floatrange.NewLinear(1, 2) @@ -274,7 +301,13 @@ func main() { render.NewCompositeR(), ) - err := oak.Init("demo", oak.FileConfig("oak.config")) + err := oak.Init("demo", oak.FileConfig("oak.config"), func(c oak.Config) (oak.Config, error) { + c.Debug.Level = "VERBOSE" + c.DrawFrameRate = 1200 + c.FrameRate = 60 + c.EnableDebugConsole = true + return c, nil + }) if err != nil { log.Fatal(err) } @@ -302,7 +335,7 @@ func parseRGBA(args []string) (r, g, b, a int, err error) { func parseFloats(args []string) (f1, f2 float64, two bool, err error) { if len(args) < 1 { - err = errors.New("No args") + err = errors.New("no args") return } f1, err = strconv.ParseFloat(args[0], 64) diff --git a/examples/particle-demo/oak.config b/examples/particle-demo/oak.config index 9088cad2..be51f96e 100644 --- a/examples/particle-demo/oak.config +++ b/examples/particle-demo/oak.config @@ -4,11 +4,5 @@ "filter": "" }, "title": "Particle Demo", - "font":{ - "hinting": "none", - "size": 12.0, - "dpi": 72.0, - "color": "white", - "file": "luxisr.ttf" - } + "enableDebugConsole": true } diff --git a/examples/screenopts/main.go b/examples/screenopts/main.go index 03ed3023..d1b0b9bd 100644 --- a/examples/screenopts/main.go +++ b/examples/screenopts/main.go @@ -17,7 +17,7 @@ const ( func main() { oak.AddScene("demo", scene.Scene{Start: func(*scene.Context) { - txt := render.NewStrText("Press F to toggle fullscreen. Press B to toggle borderless.", 50, 50) + txt := render.NewText("Press F to toggle fullscreen. Press B to toggle borderless.", 50, 50) render.Draw(txt) borderless := borderlessAtStart diff --git a/examples/slide/show/fonts.go b/examples/slide/show/fonts.go index c9039bac..430099fb 100644 --- a/examples/slide/show/fonts.go +++ b/examples/slide/show/fonts.go @@ -51,10 +51,6 @@ func FontColor(c color.Color) FontMod { }) } -// todo: we need to do this because some things -// haven't started in the engine yet (the engine -// doesn't know what our directories are for assets) -// Can we change this? func fpFilter(file string) string { return filepath.Join("assets", "font", file) } diff --git a/examples/slide/show/helpers.go b/examples/slide/show/helpers.go index fe6ee905..314ec46d 100644 --- a/examples/slide/show/helpers.go +++ b/examples/slide/show/helpers.go @@ -42,7 +42,7 @@ func TxtSetAt(f *render.Font, xpos, ypos, xadv, yadv float64, txts ...string) [] // TxtAt creates string on screen at a given location func TxtAt(f *render.Font, txt string, xpos, ypos float64) render.Renderable { - return Pos(f.NewStrText(txt, 0, 0), xpos, ypos) + return Pos(f.NewText(txt, 0, 0), xpos, ypos) } // Title draws a string as the title of a slide @@ -68,7 +68,7 @@ func TxtSetFrom(f *render.Font, xpos, ypos, xadv, yadv float64, txts ...string) // TxtFrom draws a new string starting from the right rather than the left func TxtFrom(f *render.Font, txt string, xpos, ypos float64) render.Renderable { - return f.NewStrText(txt, width*xpos, height*ypos) + return f.NewText(txt, width*xpos, height*ypos) } // Pos sets the center x and y for a renderable @@ -158,7 +158,7 @@ func ImageCaption(file string, xpos, ypos float64, scale float64, f *render.Font x := r.X() + float64(w)/2 y := r.Y() + float64(h) + 28 - s := f.NewStrText(cap, x, y) + s := f.NewText(cap, x, y) s.Center() return render.NewCompositeR(r, s) diff --git a/examples/slide/show/slide.go b/examples/slide/show/slide.go index 7eebceb3..b4376b9c 100644 --- a/examples/slide/show/slide.go +++ b/examples/slide/show/slide.go @@ -7,6 +7,7 @@ import ( "strconv" oak "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/debugstream" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/render" "github.com/oakmound/oak/v3/scene" @@ -31,15 +32,15 @@ var ( ) func AddNumberShortcuts(max int) { - oak.AddCommand("slide", func(args []string) { + debugstream.AddCommand(debugstream.Command{Name: "slide", Operation: func(args []string) string { if len(args) < 2 { - return + return "" } v := args[1] i, err := strconv.Atoi(v) if err != nil { fmt.Println(err) - return + return "" } if i < 0 { skipTo = "0" @@ -49,7 +50,8 @@ func AddNumberShortcuts(max int) { skipTo = strconv.Itoa(max) } skip = true - }) + return "" + }}) } func Start(width, height int, slides ...Slide) { @@ -87,14 +89,13 @@ func Start(width, height int, slides ...Slide) { var oldBackground image.Image - // Todo: customizable end slide oak.AddScene("slide"+strconv.Itoa(len(slides)), scene.Scene{ Start: func(ctx *scene.Context) { oldBackground = oak.GetBackgroundImage() oak.SetColorBackground(image.NewUniform(color.RGBA{0, 0, 0, 255})) render.Draw( - Express.NewStrText( + Express.NewText( "Spacebar to restart show ...", float64(ctx.Window.Width()/2), float64(ctx.Window.Height()-50), @@ -121,6 +122,7 @@ func Start(width, height int, slides ...Slide) { c.Screen.Height = height c.FrameRate = 30 c.DrawFrameRate = 30 + c.EnableDebugConsole = true return c, nil }) } diff --git a/examples/sprite-demo/main.go b/examples/sprite-demo/main.go index 0ec3d3ce..2eec0116 100644 --- a/examples/sprite-demo/main.go +++ b/examples/sprite-demo/main.go @@ -1,12 +1,12 @@ package main import ( + "fmt" "image" "math/rand" "path/filepath" oak "github.com/oakmound/oak/v3" - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/entities" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/render" @@ -48,7 +48,7 @@ func main() { for i := 0; i < 360; i++ { s, err := render.LoadSprite(filepath.Join("assets", "images"), filepath.Join("raw", "gopher11.png")) if err != nil { - dlog.Error(err) + fmt.Println(err) return } s.Modify(mod.Rotate(float32(i))) diff --git a/examples/svg/assets/images/TestShapes.svg b/examples/svg/assets/images/TestShapes.svg new file mode 100644 index 00000000..26de7ec0 --- /dev/null +++ b/examples/svg/assets/images/TestShapes.svg @@ -0,0 +1,37 @@ + + Example triangle01- simple example of a 'path' + A path that draws a triangle + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/svg/go.mod b/examples/svg/go.mod new file mode 100644 index 00000000..5e8fbeb3 --- /dev/null +++ b/examples/svg/go.mod @@ -0,0 +1,13 @@ +module github.com/oakmound/oak/examples/svg + +go 1.16 + +require ( + github.com/oakmound/oak/v3 v3.0.0-alpha.1 + github.com/srwiley/oksvg v0.0.0-20210320200257-875f767ac39a + github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9 + golang.org/x/image v0.0.0-20210504121937-7319ad40d33e // indirect + golang.org/x/net v0.0.0-20210521195947-fe42d452be8f // indirect +) + +replace github.com/oakmound/oak/v3 => ../.. diff --git a/examples/svg/go.sum b/examples/svg/go.sum new file mode 100644 index 00000000..88ffd498 --- /dev/null +++ b/examples/svg/go.sum @@ -0,0 +1,72 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 h1:+PdD6GLKejR9DizMAKT5DpSAkKswvZrurk1/eEt9+pw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= +github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= +github.com/disintegration/gift v1.2.0 h1:VMQeei2F+ZtsHjMgP6Sdt1kFjRhs2lGz8ljEOPeIR50= +github.com/disintegration/gift v1.2.0/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d/go.mod h1:CHkHWWZ4kbGY6jEy1+qlitDaCtRgNvCOQdakj/1Yl/Q= +github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1/go.mod h1:frG94byMNy+1CgGrQ25dZ+17tf98EN+OYBQL4Zh612M= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb h1:T6gaWBvRzJjuOrdCtg8fXXjKai2xSDqWTcKFUPuw8Tw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/hajimehoshi/go-mp3 v0.3.1 h1:pn/SKU1+/rfK8KaZXdGEC2G/KCB2aLRjbTCrwKcokao= +github.com/hajimehoshi/go-mp3 v0.3.1/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/oakmound/libudev v0.2.1 h1:gaXuw7Pbt3RSRxbUakAjl0dSW6Wo3TZWpwS5aMq8+EA= +github.com/oakmound/libudev v0.2.1/go.mod h1:zYF5CkHY+UP6lzWbPR+XoVAscl/s+OncWA//qWjMLUs= +github.com/oakmound/w32 v2.1.0+incompatible h1:vIkC6eJVOaAnwTTOyiVCGh24GoryPRmcvWq3cekkG2U= +github.com/oakmound/w32 v2.1.0+incompatible/go.mod h1:lzloWlclSXIU4cDr67WF8qjFFDO8gHHBIk4Qqe90enQ= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf h1:od9gEl9UQ/QNHlgYlgsSaC5SZ+CGbvO2/PCIgserJc0= +github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf/go.mod h1:RBXkZ8n2vvtdJP6PO+TbU/N/DVuCDwUN53CU+C1pJOs= +github.com/srwiley/oksvg v0.0.0-20210320200257-875f767ac39a h1:Lhe6HPtH4ndWfV56fWc4/yQhOP3vEGlwl5nfPyBxUAg= +github.com/srwiley/oksvg v0.0.0-20210320200257-875f767ac39a/go.mod h1:afMbS0qvv1m5tfENCwnOdZGOF8RGR/FsZ7bvBxQGZG4= +github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9 h1:m59mIOBO4kfcNCEzJNy71UkeF4XIx2EVmL9KLwDQdmM= +github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 h1:I8PHpJWTMTJZVDoosy8aXslFGe7wvcUbol7fOrVy4Tc= +github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370/go.mod h1:CaowXBWOiSGWEpBBV8LoVnQTVPV4ycyviC9IBLj8dRw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk= +golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 h1:h+GZ3ubjuWaQjGe8owMGcmMVCqs0xYJtRG5y2bpHaqU= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210521195947-fe42d452be8f h1:Si4U+UcgJzya9kpiEUJKQvjr512OLli+gL4poHrz93U= +golang.org/x/net v0.0.0-20210521195947-fe42d452be8f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/examples/svg/main.go b/examples/svg/main.go new file mode 100644 index 00000000..18235c42 --- /dev/null +++ b/examples/svg/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "image" + + "github.com/oakmound/oak/v3" + "github.com/oakmound/oak/v3/render" + "github.com/oakmound/oak/v3/scene" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" +) + +func main() { + oak.AddScene("svg", scene.Scene{ + Start: func(*scene.Context) { + // load svg + // svg from oksvg testdata + icon, err := oksvg.ReadIcon("./assets/images/TestShapes.svg") + if err != nil { + fmt.Println(err) + } + // put it in the thing + + inputW, inputH := icon.ViewBox.W, icon.ViewBox.H + iconAspect := inputW / inputH + const width = 640 + const height = 480 + + buff := image.NewRGBA(image.Rect(0, 0, width, height)) + + viewAspect := float64(width) / float64(height) + outputW, outputH := width, height + if viewAspect > iconAspect { + outputW = int(float64(height) * iconAspect) + } else if viewAspect < iconAspect { + outputH = int(float64(width) / iconAspect) + } + scanner := rasterx.NewScannerGV(int(inputW), int(inputH), buff, image.Rect(0, 0, width, height)) + scanner.SetBounds(10000, 10000) + dasher := rasterx.NewDasher(width, height, scanner) + icon.SetTarget(0, 0, float64(outputW), float64(outputH)) + icon.Draw(dasher, 1) + + render.Draw(render.NewSprite(0, 0, buff)) + }, + }) + + oak.Init("svg") +} diff --git a/examples/text-demo-1/main.go b/examples/text-demo-1/main.go index b00220c0..2cf4eb74 100644 --- a/examples/text-demo-1/main.go +++ b/examples/text-demo-1/main.go @@ -46,10 +46,10 @@ func main() { r = 255 font, _ = fg.Generate() txts := []*render.Text{ - font.NewStrText("Rainbow", 200, 200), - font.NewText(floatStringer{&r}, 200, 250), - font.NewText(floatStringer{&g}, 320, 250), - font.NewText(floatStringer{&b}, 440, 250), + font.NewText("Rainbow", 200, 200), + font.NewStringerText(floatStringer{&r}, 200, 250), + font.NewStringerText(floatStringer{&g}, 320, 250), + font.NewStringerText(floatStringer{&b}, 440, 250), } for _, txt := range txts { txt.SetFont(font) @@ -59,9 +59,9 @@ func main() { font2.Color = image.NewUniform(color.RGBA{255, 255, 255, 255}) font2, _ = font2.Generate() // Could give each r,g,b a color which is just the r,g,b value - render.Draw(font2.NewStrText("r", 170, 250), 0) - render.Draw(font2.NewStrText("g", 290, 250), 0) - render.Draw(font2.NewStrText("b", 410, 250), 0) + render.Draw(font2.NewText("r", 170, 250), 0) + render.Draw(font2.NewText("g", 290, 250), 0) + render.Draw(font2.NewText("b", 410, 250), 0) }, Loop: func() bool { r = limit.EnforceRange(r + diff.Poll()) diff --git a/examples/text-demo-2/main.go b/examples/text-demo-2/main.go index 72f3c3b1..91c808fd 100644 --- a/examples/text-demo-2/main.go +++ b/examples/text-demo-2/main.go @@ -55,7 +55,7 @@ func main() { for y := 0.0; y <= 480; y += strSize { str := randomStr(strlen) - strs = append(strs, font.NewStrText(str, 0, y)) + strs = append(strs, font.NewText(str, 0, y)) render.Draw(strs[len(strs)-1], 0) } }, diff --git a/examples/titlescreen-demo/main.go b/examples/titlescreen-demo/main.go index f6c2bf92..9cda7718 100644 --- a/examples/titlescreen-demo/main.go +++ b/examples/titlescreen-demo/main.go @@ -40,7 +40,7 @@ func main() { oak.AddScene("titlescreen", scene.Scene{Start: func(ctx *scene.Context) { //create text saying titlescreen in placeholder position - titleText := render.NewStrText("titlescreen", 0, 0) + titleText := render.NewText("titlescreen", 0, 0) //center text along both axes center(ctx, titleText, Both) @@ -49,7 +49,7 @@ func main() { render.Draw(titleText) //do the same for the text with button instuctions, but this time Y position is not a placeholder (X still is) - instructionText := render.NewStrText("press Enter to start, or press Q to quit", 0, float64(ctx.Window.Height()*3/4)) + instructionText := render.NewText("press Enter to start, or press Q to quit", 0, float64(ctx.Window.Height()*3/4)) //this time we only center the X axis, otherwise it would overlap titleText center(ctx, instructionText, X) render.Draw(instructionText) @@ -80,7 +80,7 @@ func main() { //we have to get the visual part specificaly, and not the whole thing. render.Draw(player.R) - controlsText := render.NewStrText("WASD to move, ESC to return to titlescreen", 5, 20) + controlsText := render.NewText("WASD to move, ESC to return to titlescreen", 5, 20) //we draw the text on layer 1 (instead of the default layer 0) //because we want it to show up above the player render.Draw(controlsText, 1) diff --git a/examples/top-down-shooter-tutorial/2-shooting/shooting.go b/examples/top-down-shooter-tutorial/2-shooting/shooting.go index 672c47e6..4d1e4493 100644 --- a/examples/top-down-shooter-tutorial/2-shooting/shooting.go +++ b/examples/top-down-shooter-tutorial/2-shooting/shooting.go @@ -26,7 +26,7 @@ var ( ) func main() { - oak.AddScene("tds", scene.Scene{Start: func(*scene.Context) { + oak.AddScene("tds", scene.Scene{Start: func(ctx *scene.Context) { playerAlive = true char := entities.NewMoving(100, 100, 32, 32, render.NewColorBox(32, 32, color.RGBA{0, 255, 0, 255}), @@ -61,8 +61,8 @@ func main() { char.Bind(mouse.Press, func(id event.CID, me interface{}) int { char := event.GetEntity(id).(*entities.Moving) - mevent := me.(mouse.Event) - render.DrawForTime( + mevent := me.(*mouse.Event) + ctx.DrawForTime( render.NewLine(char.X()+char.W/2, char.Y()+char.H/2, mevent.X(), mevent.Y(), color.RGBA{0, 128, 0, 128}), time.Millisecond*50, 1) diff --git a/examples/top-down-shooter-tutorial/3-enemies/enemies.go b/examples/top-down-shooter-tutorial/3-enemies/enemies.go index 6d7d20bf..87502e0f 100644 --- a/examples/top-down-shooter-tutorial/3-enemies/enemies.go +++ b/examples/top-down-shooter-tutorial/3-enemies/enemies.go @@ -71,7 +71,7 @@ func main() { char.Bind(mouse.Press, func(id event.CID, me interface{}) int { char := event.GetEntity(id).(*entities.Moving) - mevent := me.(mouse.Event) + mevent := me.(*mouse.Event) x := char.X() + char.W/2 y := char.Y() + char.H/2 ray.DefaultCaster.CastDistance = floatgeom.Point2{x, y}.Sub(floatgeom.Point2{mevent.X(), mevent.Y()}).Magnitude() @@ -79,7 +79,7 @@ func main() { for _, hit := range hits { hit.Zone.CID.Trigger("Destroy", nil) } - render.DrawForTime( + ctx.DrawForTime( render.NewLine(x, y, mevent.X(), mevent.Y(), color.RGBA{0, 128, 0, 128}), time.Millisecond*50, 1) diff --git a/examples/top-down-shooter-tutorial/4-sprites/sprites.go b/examples/top-down-shooter-tutorial/4-sprites/sprites.go index 5e2b913b..0993a989 100644 --- a/examples/top-down-shooter-tutorial/4-sprites/sprites.go +++ b/examples/top-down-shooter-tutorial/4-sprites/sprites.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "image/color" "math/rand" "path/filepath" @@ -57,7 +58,7 @@ func main() { "right": eggplant.Copy().Modify(mod.FlipX), }) if err != nil { - dlog.Error(err) + fmt.Println(err) } char := entities.NewMoving(100, 100, 32, 32, playerR, @@ -105,7 +106,7 @@ func main() { char.Bind(mouse.Press, func(id event.CID, me interface{}) int { char := event.GetEntity(id).(*entities.Moving) - mevent := me.(mouse.Event) + mevent := me.(*mouse.Event) x := char.X() + char.W/2 y := char.Y() + char.H/2 ray.DefaultCaster.CastDistance = floatgeom.Point2{x, y}.Sub(floatgeom.Point2{mevent.X(), mevent.Y()}).Magnitude() @@ -113,7 +114,7 @@ func main() { for _, hit := range hits { hit.Zone.CID.Trigger("Destroy", nil) } - render.DrawForTime( + ctx.DrawForTime( render.NewLine(x, y, mevent.X(), mevent.Y(), color.RGBA{0, 128, 0, 128}), time.Millisecond*50, 2) diff --git a/examples/top-down-shooter-tutorial/5-viewport/viewport.go b/examples/top-down-shooter-tutorial/5-viewport/viewport.go index 95abe410..b9ee2ef4 100644 --- a/examples/top-down-shooter-tutorial/5-viewport/viewport.go +++ b/examples/top-down-shooter-tutorial/5-viewport/viewport.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "image/color" "math/rand" "path/filepath" @@ -65,7 +66,7 @@ func main() { "right": eggplant.Copy().Modify(mod.FlipX), }) if err != nil { - dlog.Error(err) + fmt.Println(err) } char := entities.NewMoving(100, 100, 32, 32, playerR, @@ -128,7 +129,7 @@ func main() { char.Bind(mouse.Press, func(id event.CID, me interface{}) int { char := event.GetEntity(id).(*entities.Moving) - mevent := me.(mouse.Event) + mevent := me.(*mouse.Event) x := char.X() + char.W/2 y := char.Y() + char.H/2 vp := ctx.Window.Viewport() @@ -139,7 +140,7 @@ func main() { for _, hit := range hits { hit.Zone.CID.Trigger("Destroy", nil) } - render.DrawForTime( + ctx.DrawForTime( render.NewLine(x, y, mx, my, color.RGBA{0, 128, 0, 128}), time.Millisecond*50, 2) diff --git a/examples/top-down-shooter-tutorial/6-performance/performance.go b/examples/top-down-shooter-tutorial/6-performance/performance.go index 1c29d2ac..8d2ff02e 100644 --- a/examples/top-down-shooter-tutorial/6-performance/performance.go +++ b/examples/top-down-shooter-tutorial/6-performance/performance.go @@ -47,8 +47,8 @@ const ( func main() { oak.AddScene("tds", scene.Scene{Start: func(ctx *scene.Context) { - render.Draw(render.NewDrawFPS(0.03, nil, 10, 10), 2, 0) - render.Draw(render.NewLogicFPS(0.03, nil, 10, 20), 2, 0) + render.Draw(render.NewDrawFPS(0, nil, 10, 10), 2, 0) + render.Draw(render.NewLogicFPS(0, nil, 10, 20), 2, 0) // Initialization playerAlive = true @@ -71,26 +71,39 @@ func main() { playerR, nil, 0, 0) - char.Speed = physics.NewVector(5, 5) + char.Speed = physics.NewVector(3, 3) playerPos = char.Point.Vector render.Draw(char.R, 1, 2) - char.Bind(event.Enter, func(id event.CID, _ interface{}) int { + screenCenter := floatgeom.Point2{ + float64(ctx.Window.Width()) / 2, + float64(ctx.Window.Height()) / 2, + } + + char.Bind(event.Enter, func(id event.CID, payload interface{}) int { char := event.GetEntity(id).(*entities.Moving) - char.Delta.Zero() + + enterPayload := payload.(event.EnterPayload) if oak.IsDown(key.W) { - char.Delta.ShiftY(-char.Speed.Y()) + char.Delta.ShiftY(-char.Speed.Y() * enterPayload.TickPercent) } if oak.IsDown(key.A) { - char.Delta.ShiftX(-char.Speed.X()) + char.Delta.ShiftX(-char.Speed.X() * enterPayload.TickPercent) } if oak.IsDown(key.S) { - char.Delta.ShiftY(char.Speed.Y()) + char.Delta.ShiftY(char.Speed.Y() * enterPayload.TickPercent) } if oak.IsDown(key.D) { - char.Delta.ShiftX(char.Speed.X()) + char.Delta.ShiftX(char.Speed.X() * enterPayload.TickPercent) } - char.ShiftPos(char.Delta.X(), char.Delta.Y()) + ctx.Window.(*oak.Window).DoBetweenDraws(func() { + char.ShiftPos(char.Delta.X(), char.Delta.Y()) + oak.SetScreen( + int(char.R.X()-screenCenter.X()), + int(char.R.Y()-screenCenter.Y()), + ) + char.Delta.Zero() + }) // Don't go out of bounds if char.X() < 0 { char.SetX(0) @@ -102,10 +115,7 @@ func main() { } else if char.Y() > fieldHeight-char.H { char.SetY(fieldHeight - char.H) } - oak.SetScreen( - int(char.R.X())-ctx.Window.Width()/2, - int(char.R.Y())-ctx.Window.Height()/2, - ) + hit := char.HitLabel(Enemy) if hit != nil { playerAlive = false @@ -128,7 +138,7 @@ func main() { char.Bind(mouse.Press, func(id event.CID, me interface{}) int { char := event.GetEntity(id).(*entities.Moving) - mevent := me.(mouse.Event) + mevent := me.(*mouse.Event) x := char.X() + char.W/2 y := char.Y() + char.H/2 vp := ctx.Window.Viewport() @@ -139,7 +149,7 @@ func main() { for _, hit := range hits { hit.Zone.CID.Trigger("Destroy", nil) } - render.DrawForTime( + ctx.DrawForTime( render.NewLine(x, y, mx, my, color.RGBA{0, 128, 0, 128}), time.Millisecond*50, 1, 2) @@ -148,9 +158,9 @@ func main() { // Create enemies periodically event.GlobalBind(event.Enter, func(_ event.CID, frames interface{}) int { - f := frames.(int) - if f%EnemyRefresh == 0 { - NewEnemy() + enterPayload := frames.(event.EnterPayload) + if enterPayload.FramesElapsed%EnemyRefresh == 0 { + go NewEnemy() } return 0 }) @@ -178,6 +188,7 @@ func main() { oak.Init("tds", func(c oak.Config) (oak.Config, error) { c.BatchLoad = true + //c.FrameRate = 30 return c, nil }) } @@ -205,13 +216,14 @@ func NewEnemy() { enemy.UpdateLabel(Enemy) - enemy.Bind(event.Enter, func(id event.CID, _ interface{}) int { + enemy.Bind(event.Enter, func(id event.CID, payload interface{}) int { enemy := event.GetEntity(id).(*entities.Solid) + enterPayload := payload.(event.EnterPayload) // move towards the player x, y := enemy.GetPos() pt := floatgeom.Point2{x, y} pt2 := floatgeom.Point2{playerPos.X(), playerPos.Y()} - delta := pt2.Sub(pt).Normalize().MulConst(EnemySpeed) + delta := pt2.Sub(pt).Normalize().MulConst(EnemySpeed * enterPayload.TickPercent) enemy.ShiftPos(delta.X(), delta.Y()) // update animation diff --git a/examples/zooming/main.go b/examples/zooming/main.go index 0f3836e4..8ad6fb16 100644 --- a/examples/zooming/main.go +++ b/examples/zooming/main.go @@ -20,7 +20,7 @@ var ( func main() { oak.AddScene("demo", scene.Scene{Start: func(*scene.Context) { - render.Draw(render.NewStrText("Controls: Arrow keys", 500, 440)) + render.Draw(render.NewText("Controls: Arrow keys", 500, 440)) // Get an image that we will illustrate zooming with later s, err := render.LoadSprite("assets", filepath.Join("raw", "mona-lisa.jpg")) diff --git a/fileutil/open.go b/fileutil/open.go index efd11d2a..d8b3eee0 100644 --- a/fileutil/open.go +++ b/fileutil/open.go @@ -50,7 +50,7 @@ func Open(file string) (io.ReadCloser, error) { // convert data to io.Reader return nopCloser{bytes.NewReader(data)}, err } - dlog.Warn("File not found in binary", rel) + dlog.Info("File not found in binary", rel) } return os.Open(file) } @@ -60,7 +60,6 @@ func ReadFile(file string) ([]byte, error) { if BindataFn != nil { rel, err := filepath.Rel(wd, file) if err != nil { - dlog.Warn("Error in rel", err) rel = file } return BindataFn(rel) @@ -78,7 +77,6 @@ func ReadDir(file string) ([]os.FileInfo, error) { dlog.Verb("Bindata not nil, reading directory", file) rel, err = filepath.Rel(wd, file) if err != nil { - dlog.Warn(err) // Just try the relative path by itself if we can't form // an absolute path. rel = file @@ -89,14 +87,13 @@ func ReadDir(file string) ([]os.FileInfo, error) { for i, s := range strs { // If the data does not contain a period, we consider it // a directory - // todo: can we supply a function that will tell us this - // so we don't make this (bad) assumption? + // todo: match embed / fs packages to remove the above bad assumption fis[i] = dummyfileinfo{s, !strings.ContainsRune(s, '.')} dlog.Verb("Creating dummy file into for", s, fis[i]) } return fis, nil } - dlog.Warn(err) + dlog.Error(err) } return ioutil.ReadDir(file) } diff --git a/go.mod b/go.mod index b4ee1df2..db2ef78f 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,21 @@ module github.com/oakmound/oak/v3 go 1.16 require ( - dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 // indirect - github.com/200sc/klangsynthese v0.2.2-0.20201022002431-a0e14a8c862b + dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 github.com/disintegration/gift v1.2.0 - github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb // indirect + github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d // indirect + github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1 + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/hajimehoshi/go-mp3 v0.3.1 // indirect + github.com/hajimehoshi/go-mp3 v0.3.1 github.com/oakmound/libudev v0.2.1 github.com/oakmound/w32 v2.1.0+incompatible - github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 // indirect + github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf + github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 golang.org/x/image v0.0.0-20201208152932-35266b937fa6 - golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 - golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 + golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872 ) diff --git a/go.sum b/go.sum index c8933728..9bdc2870 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,16 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037 h1:+PdD6GLKejR9DizMAKT5DpSAkKswvZrurk1/eEt9+pw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/200sc/klangsynthese v0.2.2-0.20201022002431-a0e14a8c862b h1:6Gk8u6eHdE6Xcn3t3BDei1e83FUUw6eH/LhOiDECHr0= -github.com/200sc/klangsynthese v0.2.2-0.20201022002431-a0e14a8c862b/go.mod h1:1yEA6LmYbsEoAPJx+JN+UQk7k5yXebfWsuM1yGPK/3s= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/gift v1.2.0 h1:VMQeei2F+ZtsHjMgP6Sdt1kFjRhs2lGz8ljEOPeIR50= github.com/disintegration/gift v1.2.0/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= -github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b h1:/wqXgpZNTP8qV1dPEApjJXlDQd5N/F9U/WEvy5SawUI= -github.com/flopp/go-findfont v0.0.0-20201114153133-e7393a00c15b/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d h1:HB5J9+f1xpkYLgWQ/RqEcbp3SEufyOIMYLoyKNKiG7E= +github.com/eaburns/bit v0.0.0-20131029213740-7bd5cd37375d/go.mod h1:CHkHWWZ4kbGY6jEy1+qlitDaCtRgNvCOQdakj/1Yl/Q= +github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1 h1:wl/ggSfTHqAy46hyzw1IlrUYwjqmXYvbJyPdH3rT7YE= +github.com/eaburns/flac v0.0.0-20171003200620-9a6fb92396d1/go.mod h1:frG94byMNy+1CgGrQ25dZ+17tf98EN+OYBQL4Zh612M= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb h1:T6gaWBvRzJjuOrdCtg8fXXjKai2xSDqWTcKFUPuw8Tw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -25,28 +24,38 @@ github.com/oakmound/w32 v2.1.0+incompatible h1:vIkC6eJVOaAnwTTOyiVCGh24GoryPRmcv github.com/oakmound/w32 v2.1.0+incompatible/go.mod h1:lzloWlclSXIU4cDr67WF8qjFFDO8gHHBIk4Qqe90enQ= github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf h1:od9gEl9UQ/QNHlgYlgsSaC5SZ+CGbvO2/PCIgserJc0= github.com/oov/directsound-go v0.0.0-20141101201356-e53e59c700bf/go.mod h1:RBXkZ8n2vvtdJP6PO+TbU/N/DVuCDwUN53CU+C1pJOs= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370 h1:I8PHpJWTMTJZVDoosy8aXslFGe7wvcUbol7fOrVy4Tc= github.com/yobert/alsa v0.0.0-20200618200352-d079056f5370/go.mod h1:CaowXBWOiSGWEpBBV8LoVnQTVPV4ycyviC9IBLj8dRw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08 h1:h+GZ3ubjuWaQjGe8owMGcmMVCqs0xYJtRG5y2bpHaqU= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872 h1:cGjJzUd8RgBw428LXP65YXni0aiGNA4Bl+ls8SmLOm8= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/init.go b/init.go index d370206c..68aff056 100644 --- a/init.go +++ b/init.go @@ -3,14 +3,15 @@ package oak import ( "fmt" "image" + "math/rand" "os" "path/filepath" + "strings" "time" "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/oakerr" "github.com/oakmound/oak/v3/render" - "github.com/oakmound/oak/v3/shiny/driver" "github.com/oakmound/oak/v3/timing" ) @@ -21,82 +22,82 @@ var ( // Init initializes the oak engine. // It spawns off an event loop of several goroutines // and loops through scenes after initialization. -func (c *Controller) Init(firstScene string, configOptions ...ConfigOption) error { +func (w *Window) Init(firstScene string, configOptions ...ConfigOption) error { var err error - c.config, err = NewConfig(configOptions...) + w.config, err = NewConfig(configOptions...) if err != nil { return fmt.Errorf("failed to create config: %w", err) } - dlog.SetLogger(dlog.NewLogger()) - dlog.CreateLogFile() + // if c.config.Screen.TargetWidth != 0 && c.config.Screen.TargetHeight != 0 { + // w, h := driver.MonitorSize() + // if w != 0 || h != 0 { + // // Todo: Modify conf.Screen.Scale + // } + // } - if c.config.Screen.TargetWidth != 0 && c.config.Screen.TargetHeight != 0 { - w, h := driver.MonitorSize() - if w != 0 || h != 0 { - // Todo: Modify conf.Screen.Scale - } - } - - lvl, err := dlog.ParseDebugLevel(c.config.Debug.Level) + lvl, err := dlog.ParseDebugLevel(w.config.Debug.Level) if err != nil { return fmt.Errorf("failed to parse debug config: %w", err) } - dlog.SetDebugLevel(lvl) - // We are intentionally using the lvl value before checking error, - // because we can only log errors through dlog itself anyway - dlog.SetDebugFilter(c.config.Debug.Filter) - oakerr.SetLanguageString(c.config.Language) + dlog.SetFilter(func(msg string) bool { + return strings.Contains(msg, w.config.Debug.Filter) + }) + err = dlog.SetLogLevel(lvl) + if err != nil { + return err + } + err = oakerr.SetLanguageString(w.config.Language) + if err != nil { + return err + } // TODO: languages - dlog.Info("Oak Init Start") - c.ScreenWidth = c.config.Screen.Width - c.ScreenHeight = c.config.Screen.Height - c.FrameRate = c.config.FrameRate - c.DrawFrameRate = c.config.DrawFrameRate - c.IdleDrawFrameRate = c.config.IdleDrawFrameRate + w.ScreenWidth = w.config.Screen.Width + w.ScreenHeight = w.config.Screen.Height + w.FrameRate = w.config.FrameRate + w.DrawFrameRate = w.config.DrawFrameRate + w.IdleDrawFrameRate = w.config.IdleDrawFrameRate // assume we are in focus on window creation - c.inFocus = true + w.inFocus = true - c.DrawTicker = timing.NewDynamicTicker() - c.DrawTicker.SetTick(timing.FPSToFrameDelay(c.DrawFrameRate)) + w.DrawTicker = time.NewTicker(timing.FPSToFrameDelay(w.DrawFrameRate)) wd, _ := os.Getwd() - render.SetFontDefaults(wd, c.config.Assets.AssetPath, c.config.Assets.FontPath, - c.config.Font.Hinting, c.config.Font.Color, c.config.Font.File, c.config.Font.Size, - c.config.Font.DPI) + render.SetFontDefaults(wd, w.config.Assets.AssetPath, w.config.Assets.FontPath, + w.config.Font.Hinting, w.config.Font.Color, w.config.Font.File, w.config.Font.Size, + w.config.Font.DPI) - if c.config.TrackInputChanges { - trackJoystickChanges() + if w.config.TrackInputChanges { + trackJoystickChanges(w.logicHandler) } - if c.config.EventRefreshRate != 0 { - c.logicHandler.SetRefreshRate(time.Duration(c.config.EventRefreshRate)) + if w.config.EventRefreshRate != 0 { + w.logicHandler.SetRefreshRate(time.Duration(w.config.EventRefreshRate)) } - // END of loading variables from configuration - seedRNG() + if !w.config.SkipRNGSeed { + // seed math/rand with time.Now, useful for minimal examples + //that would tend to forget to do this. + rand.Seed(time.Now().UTC().UnixNano()) + } imageDir := filepath.Join(wd, - c.config.Assets.AssetPath, - c.config.Assets.ImagePath) + w.config.Assets.AssetPath, + w.config.Assets.ImagePath) audioDir := filepath.Join(wd, - c.config.Assets.AssetPath, - c.config.Assets.AudioPath) + w.config.Assets.AssetPath, + w.config.Assets.AudioPath) // TODO: languages - dlog.Info("Init Scene Loop") - go c.sceneLoop(firstScene, c.config.TrackInputChanges) - dlog.Info("Init asset load") + go w.sceneLoop(firstScene, w.config.TrackInputChanges) render.SetAssetPaths(imageDir) - go c.loadAssets(imageDir, audioDir) - if c.config.EnableDebugConsole { - dlog.Info("Init Console") - go c.debugConsole(os.Stdin) + go w.loadAssets(imageDir, audioDir) + if w.config.EnableDebugConsole { + go w.debugConsole(os.Stdin, os.Stdout) } - dlog.Info("Init Main Driver") - c.Driver(c.lifecycleLoop) - return nil + w.Driver(w.lifecycleLoop) + return w.exitError } diff --git a/inputLoop.go b/inputLoop.go index 87cba1cc..79357d05 100644 --- a/inputLoop.go +++ b/inputLoop.go @@ -7,21 +7,15 @@ import ( "github.com/oakmound/oak/v3/dlog" okey "github.com/oakmound/oak/v3/key" omouse "github.com/oakmound/oak/v3/mouse" - "github.com/oakmound/oak/v3/shiny/gesture" "golang.org/x/mobile/event/key" "golang.org/x/mobile/event/lifecycle" "golang.org/x/mobile/event/mouse" "golang.org/x/mobile/event/size" ) -var ( - eFilter gesture.EventFilter - eventFn func() interface{} -) - -func (c *Controller) inputLoop() { +func (w *Window) inputLoop() { for { - switch e := c.windowControl.NextEvent().(type) { + switch e := w.windowControl.NextEvent().(type) { // We only currently respond to death lifecycle events. case lifecycle.Event: switch e.To { @@ -29,26 +23,25 @@ func (c *Controller) inputLoop() { dlog.Info("Window closed.") // OnStop needs to be sent through TriggerBack, otherwise the // program will close before the stop events get propagated. - dlog.Verb("Triggering OnStop.") - <-c.logicHandler.TriggerBack(event.OnStop, nil) - close(c.quitCh) + <-w.logicHandler.TriggerBack(event.OnStop, nil) + close(w.quitCh) return case lifecycle.StageFocused: - c.inFocus = true + w.inFocus = true // If you are in focused state, we don't care how you got there - c.DrawTicker.SetTick(timing.FPSToFrameDelay(c.DrawFrameRate)) - c.logicHandler.Trigger(event.FocusGain, nil) + w.DrawTicker.Reset(timing.FPSToFrameDelay(w.DrawFrameRate)) + w.logicHandler.Trigger(event.FocusGain, nil) case lifecycle.StageVisible: // If the last state was focused, this means the app is out of focus // otherwise, we're visible for the first time if e.From > e.To { - c.inFocus = false - c.DrawTicker.SetTick(timing.FPSToFrameDelay(c.IdleDrawFrameRate)) - c.logicHandler.Trigger(event.FocusLoss, nil) + w.inFocus = false + w.DrawTicker.Reset(timing.FPSToFrameDelay(w.IdleDrawFrameRate)) + w.logicHandler.Trigger(event.FocusLoss, nil) } else { - c.inFocus = true - c.DrawTicker.SetTick(timing.FPSToFrameDelay(c.DrawFrameRate)) - c.logicHandler.Trigger(event.FocusGain, nil) + w.inFocus = true + w.DrawTicker.Reset(timing.FPSToFrameDelay(w.DrawFrameRate)) + w.logicHandler.Trigger(event.FocusGain, nil) } } // Send key events @@ -59,14 +52,13 @@ func (c *Controller) inputLoop() { // The specific key that is pressed is passed as the data interface for // the former events, but not for the latter. case key.Event: - // TODO v3: reevaluate key bindings-- we need the rune this event has switch e.Direction { case key.DirPress: - c.TriggerKeyDown(okey.Event(e)) + w.TriggerKeyDown(okey.Event(e)) case key.DirRelease: - c.TriggerKeyUp(okey.Event(e)) + w.TriggerKeyUp(okey.Event(e)) default: - c.TriggerKeyHeld(okey.Event(e)) + w.TriggerKeyHeld(okey.Event(e)) } // Send mouse events @@ -89,23 +81,18 @@ func (c *Controller) inputLoop() { // render and collision space. I.e. if the viewport is at 0, the mouse's // position is exactly the same as the position of a visible entity // on screen. When not at zero, the offset will be exactly the viewport. - // Todo: consider incorporating viewport into the event, see the - // workaround needed in mouseDetails, and how mouse events might not - // propagate to their expected position. mevent := omouse.NewEvent( - float64((((e.X - float32(c.windowRect.Min.X)) / float32(c.windowRect.Max.X-c.windowRect.Min.X)) * float32(c.ScreenWidth))), - float64((((e.Y - float32(c.windowRect.Min.Y)) / float32(c.windowRect.Max.Y-c.windowRect.Min.Y)) * float32(c.ScreenHeight))), + float64((((e.X - float32(w.windowRect.Min.X)) / float32(w.windowRect.Max.X-w.windowRect.Min.X)) * float32(w.ScreenWidth))), + float64((((e.Y - float32(w.windowRect.Min.Y)) / float32(w.windowRect.Max.Y-w.windowRect.Min.Y)) * float32(w.ScreenHeight))), button, eventName, ) - c.TriggerMouseEvent(mevent) - - // There's something called a paint event that we don't respond to + w.TriggerMouseEvent(mevent) // Size events update what we scale the screen to case size.Event: - //dlog.Verb("Got size event", e) - c.ChangeWindow(e.WidthPx, e.HeightPx) + err := w.ChangeWindow(e.WidthPx, e.HeightPx) + dlog.ErrorCheck(err) } } } @@ -114,43 +101,44 @@ func (c *Controller) inputLoop() { // This should be used cautiously when the keyboard is in use. // From the perspective of the event handler this is indistinguishable // from a real keypress. -func (c *Controller) TriggerKeyDown(e okey.Event) { +func (w *Window) TriggerKeyDown(e okey.Event) { k := e.Code.String()[4:] - c.SetDown(k) - c.logicHandler.Trigger(okey.Down, e) - c.logicHandler.Trigger(okey.Down+k, e) + w.SetDown(k) + w.logicHandler.Trigger(okey.Down, e) + w.logicHandler.Trigger(okey.Down+k, e) } // TriggerKeyUp triggers a software-emulated key release. // This should be used cautiously when the keyboard is in use. // From the perspective of the event handler this is indistinguishable // from a real key release. -func (c *Controller) TriggerKeyUp(e okey.Event) { +func (w *Window) TriggerKeyUp(e okey.Event) { k := e.Code.String()[4:] - c.SetUp(k) - c.logicHandler.Trigger(okey.Up, e) - c.logicHandler.Trigger(okey.Up+k, e) + w.SetUp(k) + w.logicHandler.Trigger(okey.Up, e) + w.logicHandler.Trigger(okey.Up+k, e) } // TriggerKeyHeld triggers a software-emulated key hold signal. // This should be used cautiously when the keyboard is in use. // From the perspective of the event handler this is indistinguishable // from a real key hold signal. -func (c *Controller) TriggerKeyHeld(e okey.Event) { +func (w *Window) TriggerKeyHeld(e okey.Event) { k := e.Code.String()[4:] - c.logicHandler.Trigger(okey.Held, e) - c.logicHandler.Trigger(okey.Held+k, e) + w.logicHandler.Trigger(okey.Held, e) + w.logicHandler.Trigger(okey.Held+k, e) } // TriggerMouseEvent triggers a software-emulated mouse event. // This should be used cautiously when the mouse is in use. // From the perspective of the event handler this is indistinguishable // from a real key mouse press or movement. -func (c *Controller) TriggerMouseEvent(mevent omouse.Event) { - c.Propagate(mevent.Event+"On", mevent) - c.logicHandler.Trigger(mevent.Event, mevent) +func (w *Window) TriggerMouseEvent(mevent omouse.Event) { + w.Propagate(mevent.Event+"On", mevent) + w.logicHandler.Trigger(mevent.Event, &mevent) - mevent.Point2[0] += float64(c.viewPos[0]) - mevent.Point2[1] += float64(c.viewPos[1]) - c.Propagate(mevent.Event+"OnRelative", mevent) + relativeEvent := mevent + relativeEvent.Point2[0] += float64(w.viewPos[0]) + relativeEvent.Point2[1] += float64(w.viewPos[1]) + w.Propagate(relativeEvent.Event+"OnRelative", relativeEvent) } diff --git a/inputLoop_test.go b/inputLoop_test.go new file mode 100644 index 00000000..cc2093f4 --- /dev/null +++ b/inputLoop_test.go @@ -0,0 +1,28 @@ +package oak + +import ( + "testing" + "time" + + "github.com/oakmound/oak/v3/event" + okey "github.com/oakmound/oak/v3/key" + "golang.org/x/mobile/event/key" +) + +func TestInputLoop(t *testing.T) { + c1 := blankScene(t) + c1.SetLogicHandler(event.NewBus(nil)) + c1.windowControl.Send(okey.Event{ + Direction: key.DirPress, + Code: key.Code0, + }) + c1.windowControl.Send(okey.Event{ + Direction: key.DirNone, + Code: key.Code0, + }) + c1.windowControl.Send(okey.Event{ + Direction: key.DirRelease, + Code: key.Code0, + }) + time.Sleep(2 * time.Second) +} diff --git a/inputTracker.go b/inputTracker.go index b4814829..bd3f83ff 100644 --- a/inputTracker.go +++ b/inputTracker.go @@ -16,45 +16,50 @@ type InputType = int32 // Supported Input Types const ( - KeyboardMouse InputType = iota - Joystick InputType = iota + InputKeyboardMouse InputType = iota + InputJoystick InputType = iota ) -var ( - // MostRecentInput tracks what input type was most recently detected. - // This is only updated if TrackInputChanges is true in the config at startup - // TODO: scope this to controllers - MostRecentInput InputType -) - -func trackInputChanges() { - event.GlobalBind(key.Down, func(event.CID, interface{}) int { - atomic.SwapInt32(&MostRecentInput, KeyboardMouse) - // TODO: Trigger that the most recent input changed? +func (w *Window) trackInputChanges() { + w.logicHandler.GlobalBind(key.Down, func(event.CID, interface{}) int { + old := atomic.SwapInt32(&w.mostRecentInput, InputKeyboardMouse) + if old != InputKeyboardMouse { + w.logicHandler.Trigger(event.InputChange, InputKeyboardMouse) + } return 0 }) - event.GlobalBind(mouse.Press, func(event.CID, interface{}) int { - atomic.SwapInt32(&MostRecentInput, KeyboardMouse) + w.logicHandler.GlobalBind(mouse.Press, func(event.CID, interface{}) int { + old := atomic.SwapInt32(&w.mostRecentInput, InputKeyboardMouse) + if old != InputKeyboardMouse { + w.logicHandler.Trigger(event.InputChange, InputKeyboardMouse) + } return 0 }) - event.GlobalBind("Tracking"+joystick.Change, func(event.CID, interface{}) int { - atomic.SwapInt32(&MostRecentInput, Joystick) + w.logicHandler.GlobalBind("Tracking"+joystick.Change, func(event.CID, interface{}) int { + old := atomic.SwapInt32(&w.mostRecentInput, InputJoystick) + if old != InputJoystick { + w.logicHandler.Trigger(event.InputChange, InputJoystick) + } return 0 }) } -type joyHandler struct{} +type joyHandler struct { + handler event.Handler +} func (jh *joyHandler) Trigger(ev string, state interface{}) { - event.Trigger("Tracking"+ev, state) + jh.handler.Trigger("Tracking"+ev, state) } -func trackJoystickChanges() { +func trackJoystickChanges(handler event.Handler) { dlog.ErrorCheck(joystick.Init()) go func() { jCh, _ := joystick.WaitForJoysticks(3 * time.Second) for j := range jCh { - j.Handler = &joyHandler{} + j.Handler = &joyHandler{ + handler: handler, + } j.Listen(nil) } }() diff --git a/inputTracker_test.go b/inputTracker_test.go new file mode 100644 index 00000000..0c2a0b35 --- /dev/null +++ b/inputTracker_test.go @@ -0,0 +1,56 @@ +package oak + +import ( + "testing" + "time" + + "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/joystick" + "github.com/oakmound/oak/v3/key" + "github.com/oakmound/oak/v3/mouse" + "github.com/oakmound/oak/v3/scene" +) + +func TestTrackInputChanges(t *testing.T) { + c1 := NewWindow() + c1.SetLogicHandler(event.NewBus(nil)) + c1.AddScene("1", scene.Scene{}) + go c1.Init("1", func(c Config) (Config, error) { + c.TrackInputChanges = true + return c, nil + }) + time.Sleep(2 * time.Second) + expectedType := new(InputType) + *expectedType = InputKeyboardMouse + failed := false + c1.logicHandler.GlobalBind(event.InputChange, func(_ event.CID, payload interface{}) int { + mode := payload.(InputType) + if mode != *expectedType { + failed = true + } + return 0 + }) + c1.TriggerKeyDown(key.Event{}) + time.Sleep(2 * time.Second) + if failed { + t.Fatalf("keyboard change failed") + } + *expectedType = InputJoystick + c1.logicHandler.Trigger("Tracking"+joystick.Change, &joystick.State{}) + time.Sleep(2 * time.Second) + if failed { + t.Fatalf("joystick change failed") + } + *expectedType = InputKeyboardMouse + c1.TriggerMouseEvent(mouse.Event{Event: mouse.Press}) + time.Sleep(2 * time.Second) + if failed { + t.Fatalf("mouse change failed") + } + c1.mostRecentInput = InputJoystick + c1.TriggerKeyDown(key.Event{}) + time.Sleep(2 * time.Second) + if failed { + t.Fatalf("keyboard change failed") + } +} diff --git a/joystick/driver_linux.go b/joystick/driver_linux.go index 5f480080..1e383fc8 100644 --- a/joystick/driver_linux.go +++ b/joystick/driver_linux.go @@ -178,6 +178,9 @@ func (j *Joystick) close() error { return j.fh.Close() } +// Joysticks contain "js%d" +var joystickRegex = regexp.MustCompile("js(\\d)+") + func getJoysticks() []*Joystick { sc := libudev.NewScanner() err, dvs := sc.ScanDevices() @@ -185,18 +188,12 @@ func getJoysticks() []*Joystick { fmt.Println(err) return nil } - // Joysticks contain "js%d" - rgx, err := regexp.Compile("js(\\d)+") - if err != nil { - dlog.Error(err) - return nil - } - + filtered := []*types.Device{} for _, d := range dvs { // Find joysticks - if !rgx.MatchString(d.Devpath) { + if !joystickRegex.MatchString(d.Devpath) { continue } // Ignore mice @@ -210,7 +207,7 @@ func getJoysticks() []*Joystick { joys := make([]*Joystick, len(filtered)) for i, f := range filtered { var id uint32 = math.MaxUint32 - matches := rgx.FindStringSubmatch(f.Devpath) + matches := joystickRegex.FindStringSubmatch(f.Devpath) if len(matches) > 1 { idint, err := strconv.Atoi(matches[1]) id = uint32(idint) diff --git a/joystick/driver_windows.go b/joystick/driver_windows.go index 597e4daf..c607ac29 100644 --- a/joystick/driver_windows.go +++ b/joystick/driver_windows.go @@ -98,7 +98,6 @@ func (j *Joystick) getState() (*State, error) { func (j *Joystick) vibrate(left, right uint16) error { j.vibration.LeftMotorSpeed = left j.vibration.RightMotorSpeed = right - // Todo: wrap these errors? return w32.XInputSetState(j.id, j.vibration) } diff --git a/joystick/joystick.go b/joystick/joystick.go index 82290129..52b161cd 100644 --- a/joystick/joystick.go +++ b/joystick/joystick.go @@ -86,6 +86,13 @@ type ListenOptions struct { // "RtStickChange": *State // "LtStickChange": *State StickChanges bool + // StickDeadzones enable preventing movement near the center of + // the analog control being sent to a logic handler. A + // StickDeadzone value will be treated as an absolute threshold. + StickDeadzoneLX int16 + StickDeadzoneLY int16 + StickDeadzoneRX int16 + StickDeadzoneRY int16 } func (lo *ListenOptions) sendFn() func(Triggerer, *State, *State) { @@ -175,30 +182,28 @@ func (lo *ListenOptions) sendFn() func(Triggerer, *State, *State) { } } } - // Todo: support a stick deadzone where we don't report very tiny changes - // near the center of the stick if lo.StickChanges { prevFn := fn if prevFn != nil { fn = func(h Triggerer, cur, last *State) { prevFn(h, cur, last) - if cur.StickLX != last.StickLX || - cur.StickLY != last.StickLY { + if deltaExceedsThreshold(cur.StickLX, last.StickLX, lo.StickDeadzoneLX) || + deltaExceedsThreshold(cur.StickLY, last.StickLY, lo.StickDeadzoneLY) { h.Trigger(LtStickChange, cur) } - if cur.StickRX != last.StickRX || - cur.StickRY != last.StickRY { + if deltaExceedsThreshold(cur.StickRX, last.StickRX, lo.StickDeadzoneRX) || + deltaExceedsThreshold(cur.StickRY, last.StickRY, lo.StickDeadzoneRY) { h.Trigger(RtStickChange, cur) } } } else { fn = func(h Triggerer, cur, last *State) { - if cur.StickLX != last.StickLX || - cur.StickLY != last.StickLY { + if deltaExceedsThreshold(cur.StickLX, last.StickLX, lo.StickDeadzoneLX) || + deltaExceedsThreshold(cur.StickLY, last.StickLY, lo.StickDeadzoneLY) { h.Trigger(LtStickChange, cur) } - if cur.StickRX != last.StickRX || - cur.StickRY != last.StickRY { + if deltaExceedsThreshold(cur.StickRX, last.StickRX, lo.StickDeadzoneRX) || + deltaExceedsThreshold(cur.StickRY, last.StickRY, lo.StickDeadzoneRY) { h.Trigger(RtStickChange, cur) } } @@ -230,6 +235,17 @@ func (lo *ListenOptions) sendFn() func(Triggerer, *State, *State) { return fn } +func deltaExceedsThreshold(old, new, threshold int16) bool { + return intAbs(old-new) > threshold +} + +func intAbs(x int16) (positiveX int16) { + if x < 0 { + return x * -1 + } + return x +} + // Listen causes the joystick to send its inputs to its Handler, by regularly // querying GetState. The type of events returned can be specified by options. // If the options are nil, only JoystickChange events will be sent. @@ -240,6 +256,18 @@ func (j *Joystick) Listen(opts *ListenOptions) (cancel func()) { } } stop := make(chan struct{}) + if opts.StickDeadzoneLX < 0 { + opts.StickDeadzoneLX *= -1 + } + if opts.StickDeadzoneRX < 0 { + opts.StickDeadzoneRX *= -1 + } + if opts.StickDeadzoneRY < 0 { + opts.StickDeadzoneRY *= -1 + } + if opts.StickDeadzoneLY < 0 { + opts.StickDeadzoneLY *= -1 + } sendFn := opts.sendFn() go func() { // Perform required initialization to receive inputs from OS diff --git a/key/keys.go b/key/keys.go index 91adba4c..ba0f2f2c 100644 --- a/key/keys.go +++ b/key/keys.go @@ -3,7 +3,6 @@ package key // This lists the keys sent through oak's input events. // This list is not used internally by oak, but was generated from // the expected output from x/mobile/key. -// todo: write a go generate script to perform said generation // // These strings are sent as payloads to Key.Down and Key.Up events, // and through "KeyDown"+$a, "KeyUp"+$a for any $a in the const. diff --git a/lifecycle.go b/lifecycle.go index a74bc1b4..179532c2 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -5,113 +5,116 @@ import ( "image/draw" "github.com/oakmound/oak/v3/alg" - "github.com/oakmound/oak/v3/dlog" + "github.com/oakmound/oak/v3/debugstream" "golang.org/x/mobile/event/lifecycle" "github.com/oakmound/oak/v3/shiny/screen" ) -func (c *Controller) lifecycleLoop(s screen.Screen) { - dlog.Info("Init Lifecycle") - - c.screenControl = s - dlog.Info("Creating window buffer") - err := c.UpdateViewSize(c.ScreenWidth, c.ScreenHeight) +func (w *Window) lifecycleLoop(s screen.Screen) { + w.screenControl = s + err := w.UpdateViewSize(w.ScreenWidth, w.ScreenHeight) if err != nil { - dlog.Error(err) + go w.exitWithError(err) return } // Right here, query the backing scale factor of the physical screen // Apply that factor to the scale - dlog.Info("Creating window controller") - c.newWindow( - int32(c.config.Screen.X), - int32(c.config.Screen.Y), - c.ScreenWidth*c.config.Screen.Scale, - c.ScreenHeight*c.config.Screen.Scale, + err = w.newWindow( + int32(w.config.Screen.X), + int32(w.config.Screen.Y), + w.ScreenWidth*w.config.Screen.Scale, + w.ScreenHeight*w.config.Screen.Scale, ) + if err != nil { + go w.exitWithError(err) + return + } - dlog.Info("Starting draw loop") - go c.drawLoop() - dlog.Info("Starting input loop") - go c.inputLoop() + go w.drawLoop() + go w.inputLoop() - <-c.quitCh + <-w.quitCh } -// Quit sends a signal to the window to close itself, ending oak. -func (c *Controller) Quit() { - c.windowControl.Send(lifecycle.Event{To: lifecycle.StageDead}) +// Quit sends a signal to the window to close itself, closing the window and +// any spun up resources. It should not be called before Init. After it is called, +// it must not be called again. +func (w *Window) Quit() { + // We could have hit this before the window was created + if w.windowControl == nil { + close(w.quitCh) + } else { + w.windowControl.Send(lifecycle.Event{To: lifecycle.StageDead}) + } + if w.config.EnableDebugConsole { + debugstream.DefaultCommands.RemoveScope(w.ControllerID) + } } -func (c *Controller) newWindow(x, y int32, width, height int) { +func (w *Window) newWindow(x, y int32, width, height int) error { // The window controller handles incoming hardware or platform events and // publishes image data to the screen. - wC, err := c.windowController(c.screenControl, x, y, width, height) + wC, err := w.windowController(w.screenControl, x, y, width, height) if err != nil { - dlog.Error(err) - panic(err) + return err } - c.windowControl = wC - c.ChangeWindow(width, height) + w.windowControl = wC + return w.ChangeWindow(width, height) } // SetAspectRatio will enforce that the displayed window does not distort the // input screen away from the given x:y ratio. The screen will not use these // settings until a new size event is received from the OS. -func (c *Controller) SetAspectRatio(xToY float64) { - c.UseAspectRatio = true - c.aspectRatio = xToY +func (w *Window) SetAspectRatio(xToY float64) { + w.UseAspectRatio = true + w.aspectRatio = xToY } // ChangeWindow sets the width and height of the game window. Although exported, // calling it without a size event will probably not act as expected. -func (c *Controller) ChangeWindow(width, height int) { +func (w *Window) ChangeWindow(width, height int) error { // Draw a black frame to cover up smears // Todo: could restrict the black to -just- the area not covered by the // scaled screen buffer - buff, err := c.screenControl.NewImage(image.Point{width, height}) + buff, err := w.screenControl.NewImage(image.Point{width, height}) if err == nil { - draw.Draw(buff.RGBA(), buff.Bounds(), c.bkgFn(), zeroPoint, draw.Src) - c.windowControl.Upload(zeroPoint, buff, buff.Bounds()) + draw.Draw(buff.RGBA(), buff.Bounds(), w.bkgFn(), zeroPoint, draw.Src) + w.windowControl.Upload(zeroPoint, buff, buff.Bounds()) } else { - dlog.Error(err) + return err } var x, y int - if c.UseAspectRatio { + if w.UseAspectRatio { inRatio := float64(width) / float64(height) - if c.aspectRatio > inRatio { - newHeight := alg.RoundF64(float64(height) * (inRatio / c.aspectRatio)) + if w.aspectRatio > inRatio { + newHeight := alg.RoundF64(float64(height) * (inRatio / w.aspectRatio)) y = (newHeight - height) / 2 height = newHeight - y } else { - newWidth := alg.RoundF64(float64(width) * (c.aspectRatio / inRatio)) + newWidth := alg.RoundF64(float64(width) * (w.aspectRatio / inRatio)) x = (newWidth - width) / 2 width = newWidth - x } } - c.windowRect = image.Rect(-x, -y, width, height) + w.windowRect = image.Rect(-x, -y, width, height) + return nil } -func (c *Controller) UpdateViewSize(width, height int) error { - c.ScreenWidth = width - c.ScreenHeight = height - newBuffer, err := c.screenControl.NewImage(image.Point{width, height}) +func (w *Window) UpdateViewSize(width, height int) error { + w.ScreenWidth = width + w.ScreenHeight = height + newBuffer, err := w.screenControl.NewImage(image.Point{width, height}) if err != nil { return err } - c.winBuffer = newBuffer - newTexture, err := c.screenControl.NewTexture(c.winBuffer.Bounds().Max) + w.winBuffer = newBuffer + newTexture, err := w.screenControl.NewTexture(w.winBuffer.Bounds().Max) if err != nil { return err } - c.windowTexture = newTexture + w.windowTexture = newTexture return nil } - -// GetScreen returns the current screen as an rgba buffer -func (c *Controller) GetScreen() *image.RGBA { - return c.winBuffer.RGBA() -} diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 00000000..9e47c537 --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,28 @@ +package oak + +import ( + "testing" +) + +func TestAspectRatio(t *testing.T) { + c1 := blankScene(t) + c1.SetAspectRatio(2) + c1.ChangeWindow(10, 10) + w := c1.windowRect.Max.X - c1.windowRect.Min.X + h := c1.windowRect.Max.Y - c1.windowRect.Min.Y + if w != 10 { + t.Fatalf("height was not 10, got %v", w) + } + if h != 5 { + t.Fatalf("height was not 5, got %v", h) + } + c1.ChangeWindow(10, 2) + w = c1.windowRect.Max.X - c1.windowRect.Min.X + h = c1.windowRect.Max.Y - c1.windowRect.Min.Y + if w != 4 { + t.Fatalf("height was not 4, got %v", w) + } + if h != 2 { + t.Fatalf("height was not 2, got %v", h) + } +} diff --git a/loading.go b/loading.go index 012b188e..fa54b651 100644 --- a/loading.go +++ b/loading.go @@ -8,21 +8,20 @@ import ( "golang.org/x/sync/errgroup" ) -func (c *Controller) loadAssets(imageDir, audioDir string) { - if c.config.BatchLoad { - dlog.Info("Loading Images") +func (w *Window) loadAssets(imageDir, audioDir string) { + if w.config.BatchLoad { var eg errgroup.Group eg.Go(func() error { - err := render.BlankBatchLoad(imageDir, c.config.BatchLoadOptions.MaxImageFileSize) + err := render.BlankBatchLoad(imageDir, w.config.BatchLoadOptions.MaxImageFileSize) if err != nil { return err } - dlog.Info("Done Loading Images") + dlog.Verb("Done Loading Images") return nil }) eg.Go(func() error { var err error - if c.config.BatchLoadOptions.BlankOutAudio { + if w.config.BatchLoadOptions.BlankOutAudio { err = audio.BlankBatchLoad(audioDir) } else { err = audio.BatchLoad(audioDir) @@ -30,21 +29,22 @@ func (c *Controller) loadAssets(imageDir, audioDir string) { if err != nil { return err } - dlog.Info("Done Loading Audio") + dlog.Verb("Done Loading Audio") return nil }) dlog.ErrorCheck(eg.Wait()) } - c.endLoad() + w.endLoad() } -func (c *Controller) endLoad() { - dlog.Info("Done Loading") - c.startupLoading = false +func (w *Window) endLoad() { + dlog.Verb("Done Loading") + w.startupLoading = false } -// SetBinaryPayload just sets some public fields on packages that require access to binary functions -// as alternatives to os file functions. +// SetBinaryPayload changes how oak will load files-- instead of loading from the filesystem, +// they'll be loaded from the provided two functions: one to load bytes from a path, +// and one to list paths underneath a directory. func SetBinaryPayload(payloadFn func(string) ([]byte, error), dirFn func(string) ([]string, error)) { fileutil.BindataDir = dirFn fileutil.BindataFn = payloadFn diff --git a/loading_test.go b/loading_test.go new file mode 100644 index 00000000..326aaadb --- /dev/null +++ b/loading_test.go @@ -0,0 +1,53 @@ +package oak + +import ( + "testing" + + "github.com/oakmound/oak/v3/scene" +) + +func TestBatchLoad_HappyPath(t *testing.T) { + c1 := NewWindow() + c1.AddScene("1", scene.Scene{ + Start: func(context *scene.Context) { + context.Window.Quit() + }, + }) + c1.Init("1", func(c Config) (Config, error) { + c.BatchLoad = true + c.Assets.AssetPath = "testdata" + return c, nil + }) +} + +func TestBatchLoad_NotFound(t *testing.T) { + c1 := NewWindow() + c1.AddScene("1", scene.Scene{ + Start: func(context *scene.Context) { + context.Window.Quit() + }, + }) + c1.Init("1", func(c Config) (Config, error) { + c.BatchLoad = true + return c, nil + }) +} + +func TestBatchLoad_Blank(t *testing.T) { + c1 := NewWindow() + c1.AddScene("1", scene.Scene{ + Start: func(context *scene.Context) { + context.Window.Quit() + }, + }) + c1.Init("1", func(c Config) (Config, error) { + c.BatchLoad = true + c.BatchLoadOptions.BlankOutAudio = true + return c, nil + }) +} + +func TestSetBinaryPayload(t *testing.T) { + // coverage test, these utilities are effectively tested in the render package + SetBinaryPayload(nil, nil) +} diff --git a/mouse/bindings.go b/mouse/bindings.go index fdf5e372..cedfe8dd 100644 --- a/mouse/bindings.go +++ b/mouse/bindings.go @@ -2,11 +2,14 @@ package mouse import "github.com/oakmound/oak/v3/event" -func Binding(fn func(event.CID, Event) int) func(event.CID, interface{}) int { +// Binding will convert a function that accepts a typecast *mouse.Event into a generic event binding +// +// Example: +// bus.Bind(mouse.ClickOn, mouse.Binding(clickHandler)) +func Binding(fn func(event.CID, *Event) int) func(event.CID, interface{}) int { return func(cid event.CID, iface interface{}) int { - me, ok := iface.(Event) + me, ok := iface.(*Event) if !ok { - // TODO: log error? return event.UnbindSingle } return fn(cid, me) diff --git a/mouse/event.go b/mouse/event.go index ae0ddefb..c185bc7d 100644 --- a/mouse/event.go +++ b/mouse/event.go @@ -23,6 +23,10 @@ type Event struct { floatgeom.Point2 Button Event string + + // Set StopPropagation on a mouse event to prevent it from triggering on + // lower layers of mouse collision spaces while in flight + StopPropagation bool } // NewEvent creates an event. @@ -34,12 +38,23 @@ func NewEvent(x, y float64, button Button, event string) Event { } } -// NewZeroEvent creates an event with no button or event. +// NewZeroEvent creates an event with no button or event name. func NewZeroEvent(x, y float64) Event { return NewEvent(x, y, ButtonNone, "") } // ToSpace converts a mouse event into a collision space func (e Event) ToSpace() *collision.Space { - return collision.NewUnassignedSpace(e.X(), e.Y(), 0.1, 0.1) + sp := collision.NewUnassignedSpace(e.X(), e.Y(), 0.1, 0.1) + sp.Location.Max[2] = MaxZLayer + sp.Location.Min[2] = MinZLayer + return sp } + +// Min and Max Z layer inform what range of z layer values will be checked +// on mouse collision interactions. Mouse events will not propagate to +// elements with z layers outside of this range. +const ( + MinZLayer = 0 + MaxZLayer = 1000 +) diff --git a/mouse/gesture.go b/mouse/gesture.go deleted file mode 100644 index 02363fc4..00000000 --- a/mouse/gesture.go +++ /dev/null @@ -1,39 +0,0 @@ -package mouse - -import ( - "time" - - "github.com/oakmound/oak/v3/physics" - "github.com/oakmound/oak/v3/shiny/gesture" -) - -// A GestureEvent is a conversion of a shiny Gesture to our local type so we -// don't need to import shiny variables in more places. -// GestureEvents contain information about mouse events that are not single actions, -// like drags, holds, and double clicks. -// Todo: standardize events to also use vectors for their position -type GestureEvent struct { - Drag bool - LongPress bool - DoublePress bool - - InitialPos physics.Vector - CurrentPos physics.Vector - - Time time.Time -} - -// FromShinyGesture converts a shiny gesture.Event to a GestureEvent -func FromShinyGesture(shinyGesture gesture.Event) GestureEvent { - - return GestureEvent{ - shinyGesture.Drag, - shinyGesture.LongPress, - shinyGesture.DoublePress, - - physics.NewVector(float64(shinyGesture.InitialPos.X), float64(shinyGesture.InitialPos.Y)), - physics.NewVector(float64(shinyGesture.CurrentPos.X), float64(shinyGesture.CurrentPos.Y)), - - shinyGesture.Time, - } -} diff --git a/mouse/gesture_test.go b/mouse/gesture_test.go deleted file mode 100644 index 7eb0ab8a..00000000 --- a/mouse/gesture_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package mouse - -import ( - "testing" - "time" - - "github.com/oakmound/oak/v3/physics" - - "github.com/oakmound/oak/v3/shiny/gesture" -) - -func TestGestureIdentity(t *testing.T) { - tm := time.Now() - ge := gesture.Event{ - Type: gesture.TypeStart, - Drag: true, - LongPress: false, - DoublePress: false, - InitialPos: gesture.Point{X: 2.0, Y: 3.0}, - CurrentPos: gesture.Point{X: 4.0, Y: 5.0}, - Time: tm, - } - mge := FromShinyGesture(ge) - if mge.DoublePress != false { - t.Fatalf("got %v expected %v", mge.DoublePress, false) - } - if mge.LongPress != false { - t.Fatalf("got %v expected %v", mge.LongPress, false) - } - if mge.Drag != true { - t.Fatalf("got %v expected %v", mge.Drag, true) - } - if mge.InitialPos.X() != 2 || mge.InitialPos.Y() != 3.0 { - t.Fatalf("got %v expected %v", mge.InitialPos, physics.NewVector(2.0, 3.0)) - } - if mge.CurrentPos.X() != 4.0 || mge.CurrentPos.Y() != 5.0 { - t.Fatalf("got %v expected %v", mge.CurrentPos, physics.NewVector(4.0, 5.0)) - } - if mge.Time != tm { - t.Fatalf("got %v expected %v", mge.Time, tm) - } -} diff --git a/mouse/onCollision.go b/mouse/onCollision.go index 73a3140d..715383d5 100644 --- a/mouse/onCollision.go +++ b/mouse/onCollision.go @@ -39,7 +39,7 @@ func PhaseCollision(s *collision.Space) error { } // MouseCollisionStart/Stop: see collision Start/Stop, for mouse collision -// Payload: (mouse.Event) +// Payload: (*mouse.Event) const ( Start = "MouseCollisionStart" Stop = "MouseCollisionStop" @@ -54,15 +54,18 @@ func phaseCollisionEnter(id event.CID, nothing interface{}) int { if ev == nil { ev = &LastEvent } + if ev.StopPropagation { + return 0 + } if oc.OnCollisionS.Contains(ev.ToSpace()) { if !oc.wasTouching { - id.Trigger(Start, *ev) + id.Trigger(Start, ev) oc.wasTouching = true } } else { if oc.wasTouching { - id.Trigger(Stop, *ev) + id.Trigger(Stop, ev) oc.wasTouching = false } } diff --git a/mouse/onCollision_test.go b/mouse/onCollision_test.go index f0f7022e..fbff918d 100644 --- a/mouse/onCollision_test.go +++ b/mouse/onCollision_test.go @@ -18,7 +18,7 @@ func (cp *cphase) Init() event.CID { } func TestCollisionPhase(t *testing.T) { - go event.ResolvePending() + go event.ResolveChanges() go func() { for { <-time.After(5 * time.Millisecond) @@ -41,12 +41,16 @@ func TestCollisionPhase(t *testing.T) { return 0 }) time.Sleep(200 * time.Millisecond) - LastEvent = Event{floatgeom.Point2{10, 10}, ButtonNone, ""} + LastEvent = Event{ + Point2: floatgeom.Point2{10, 10}, + } time.Sleep(200 * time.Millisecond) if !active { t.Fatalf("phase collision did not trigger") } - LastEvent = Event{floatgeom.Point2{21, 21}, ButtonNone, ""} + LastEvent = Event{ + Point2: floatgeom.Point2{21, 21}, + } time.Sleep(200 * time.Millisecond) if active { t.Fatalf("phase collision triggered innapropriately") diff --git a/mouse/strings.go b/mouse/strings.go index 9d7f2bd5..cdc1c24e 100644 --- a/mouse/strings.go +++ b/mouse/strings.go @@ -1,7 +1,7 @@ package mouse // Mouse events: MousePress, MouseRelease, MouseScrollDown, MouseScrollUp, MouseDrag -// Payload: (mouse.Event) details of the mouse event +// Payload: (*mouse.Event) details of the mouse event const ( Press = "MousePress" Release = "MouseRelease" diff --git a/oakerr/errors.go b/oakerr/errors.go index 56677881..a74c667a 100644 --- a/oakerr/errors.go +++ b/oakerr/errors.go @@ -79,9 +79,6 @@ func (ii IndivisibleInput) Error() string { return errorString(codeIndivisibleInput, ii.InputName, ii.MustDivideBy) } -// Todo: compose InvalidInput into other invalid input esque structs, add -// constructors. - // InvalidInput is a generic struct returned for otherwise invalid input. type InvalidInput struct { InputName string diff --git a/oakerr/errors_test.go b/oakerr/errors_test.go index fdaa021c..c669e015 100644 --- a/oakerr/errors_test.go +++ b/oakerr/errors_test.go @@ -5,7 +5,7 @@ import ( ) func TestErrorsAreErrors(t *testing.T) { - languages := []Language{English, Deutsch} + languages := []Language{EN, DE} for _, lang := range languages { SetLanguage(lang) var err error = NotFound{} @@ -49,7 +49,7 @@ func TestErrorsAreErrors(t *testing.T) { } func TestErrorFallback(t *testing.T) { - SetLanguage(日本語) + SetLanguage(JP) s := errorString(codeIndivisibleInput, "a", "b") if s != "a was not divisible by b" { t.Fatalf("language fallback to english failed") diff --git a/oakerr/format_string.go b/oakerr/format_string.go index bbcf7769..e9fee521 100644 --- a/oakerr/format_string.go +++ b/oakerr/format_string.go @@ -19,13 +19,13 @@ const ( func errorString(code errCode, inputs ...interface{}) string { format, ok := errFmtStrings[currentLanguage][code] if !ok { - format = errFmtStrings[English][code] + format = errFmtStrings[EN][code] } return fmt.Sprintf(format, inputs...) } var errFmtStrings = map[Language]map[errCode]string{ - English: { + EN: { codeNotFound: "%v was not found", codeExistingElement: "%1v %2v already defined", codeExistingElementOverwritten: "%1v %2v already defined, old %2v overwritten", @@ -36,7 +36,7 @@ var errFmtStrings = map[Language]map[errCode]string{ codeInvalidInput: "invalid input: %v", codeUnsupportedPlatform: "%v is not supported on this platform", }, - Deutsch: { + DE: { codeNotFound: "%v nicht gefunden", codeExistingElement: "%1v %2v schon definiert", codeExistingElementOverwritten: "%1v %2v schon definiert, alterer %2v uberschreiben", @@ -47,7 +47,7 @@ var errFmtStrings = map[Language]map[errCode]string{ codeInvalidInput: "ungültige Eingabe: %v", codeUnsupportedPlatform: "%v ist auf diesem betriebssystem nicht unterstützt", }, - 日本語: { + JP: { codeNotFound: "%qが見つからない", //codeExistingElement: "%1q%2qはもう存在します" //codeExistingElementOverwritten: "%1q%2qはすでに存在し、古い%2qは上書きされます" diff --git a/oakerr/language.go b/oakerr/language.go index 2272c1e5..a96af530 100644 --- a/oakerr/language.go +++ b/oakerr/language.go @@ -2,15 +2,13 @@ package oakerr import ( "strings" - - "github.com/oakmound/oak/v3/dlog" ) // Language configures the language of returned error strings type Language int var ( - currentLanguage Language = English + currentLanguage Language = EN ) func SetLanguage(l Language) { @@ -18,28 +16,26 @@ func SetLanguage(l Language) { } // SetLanguageString parses a string as a language -func SetLanguageString(s string) { - s = strings.ToUpper(s) - switch s { - case "ENGLISH": - currentLanguage = English - case "GERMAN", "DEUTSCH": - currentLanguage = Deutsch - case "JAPANESE", "日本語": - currentLanguage = 日本語 +func SetLanguageString(language string) error { + language = strings.ToUpper(language) + switch language { + case "EN", "ENGLISH": + currentLanguage = EN + case "DE", "GERMAN", "DEUTSCH": + currentLanguage = DE + case "JP", "JAPANESE", "日本語": + currentLanguage = JP default: - // This should be the only always-english language string logged or returned by the engine - dlog.Warn("Unknown language:", s, "Language set to English") - currentLanguage = English + return InvalidInput{InputName: language} } + return nil } -// Valid languages -// TODO: should we be using ISO 639 codes? If so, ISO 639-1? ISO 639-3? +// Valid languages, approximately matching ISO 639-1 const ( - English Language = iota - Deutsch - 日本語 + EN Language = iota + DE + JP ) // Q: Why these languages? diff --git a/oakerr/language_test.go b/oakerr/language_test.go index 3afe70c1..cd092a01 100644 --- a/oakerr/language_test.go +++ b/oakerr/language_test.go @@ -5,24 +5,24 @@ import ( ) func TestSetLanguageString(t *testing.T) { - SetLanguageString("Gibberish") - if currentLanguage != English { - t.Fatalf("Gibberish did not set language to English") + err := SetLanguageString("Gibberish") + if err == nil { + t.Fatal("Setting to language Gibberish did not error") } SetLanguageString("German") - if currentLanguage != Deutsch { + if currentLanguage != DE { t.Fatalf("German did not set language to Deutsch") } SetLanguageString("English") - if currentLanguage != English { + if currentLanguage != EN { t.Fatalf("English did not set language to English") } SetLanguageString("Japanese") - if currentLanguage != 日本語 { + if currentLanguage != JP { t.Fatalf("Japanese did not set language to 日本語") } SetLanguageString("日本語") - if currentLanguage != 日本語 { + if currentLanguage != JP { t.Fatalf("日本語 did not set language to 日本語") } } diff --git a/physics/force.go b/physics/force.go index f0b364be..a0b8f2f7 100644 --- a/physics/force.go +++ b/physics/force.go @@ -2,8 +2,6 @@ package physics import ( "github.com/oakmound/oak/v3/oakerr" - - "github.com/oakmound/oak/v3/dlog" ) const frozen = -64 @@ -74,10 +72,8 @@ type Pushes interface { // Push applies the force from the pushing object its target func Push(a Pushes, b Pushable) error { - dlog.Verb("Pushing", b.GetMass()) if b.GetMass() <= 0 { if b.GetMass() != frozen { - // Todo: this could be more specific return oakerr.InsufficientInputs{InputName: "Mass", AtLeast: 0} } return nil @@ -86,6 +82,5 @@ func Push(a Pushes, b Pushable) error { fdirection := a.GetForce().Copy() totalF := *a.GetForce().Force / b.GetMass() b.GetDelta().Add(fdirection.Normalize().Scale(totalF)) - dlog.Verb("Total Force was ", totalF, " fdirection ", fdirection.X(), fdirection.Y()) return nil } diff --git a/render/batchload.go b/render/batchload.go index 19f91490..4cd9e9d3 100644 --- a/render/batchload.go +++ b/render/batchload.go @@ -11,6 +11,7 @@ import ( "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/fileutil" + "github.com/oakmound/oak/v3/oakerr" ) var ( @@ -35,20 +36,19 @@ func BatchLoad(baseFolder string) error { func BlankBatchLoad(baseFolder string, maxFileSize int64) error { folders, err := fileutil.ReadDir(baseFolder) if err != nil { - dlog.Error(err) return err } - aliases := parseAliasFile(baseFolder) + aliases, err := parseAliasFile(baseFolder) + if err != nil { + return err + } warnFiles := []string{} var wg sync.WaitGroup - for i, folder := range folders { - - dlog.Verb("folder ", i, folder.Name()) + for _, folder := range folders { if folder.IsDir() { - frameW, frameH, possibleSheet, err := parseLoadFolderName(aliases, folder.Name()) if err != nil { return err @@ -59,7 +59,6 @@ func BlankBatchLoad(baseFolder string, maxFileSize int64) error { if !file.IsDir() { name := file.Name() if _, ok := fileDecoders[strings.ToLower(name[len(name)-4:])]; ok { - dlog.Verb("loading file ", name) lower := strings.ToLower(name) relativePath := filepath.Join(folder.Name(), name) if lower != name { @@ -81,8 +80,6 @@ func BlankBatchLoad(baseFolder string, maxFileSize int64) error { w := buff.Bounds().Max.X h := buff.Bounds().Max.Y - dlog.Verb("buffer: ", w, h, " frame: ", frameW, frameH) - if w < frameW || h < frameH { dlog.Error("File ", name, " in folder", folder.Name(), " is too small for folder dimensions", frameW, frameH) @@ -90,7 +87,6 @@ func BlankBatchLoad(baseFolder string, maxFileSize int64) error { // Load this as a sheet if it is greater // than the folder size's frame size } else if w != frameW || h != frameH { - dlog.Verb("Loading as sprite sheet") _, err = LoadSheet(baseFolder, relativePath, frameW, frameH, defaultPad) dlog.ErrorCheck(err) } @@ -100,36 +96,33 @@ func BlankBatchLoad(baseFolder string, maxFileSize int64) error { } } } - } else { - dlog.Verb("Not Folder", folder.Name()) } } if len(warnFiles) != 0 { fileNames := strings.Join(warnFiles, ",") - dlog.Warn("The files", fileNames, "are not all lowercase. This may cause data to fail to load"+ + dlog.Info("The files", fileNames, "are not all lowercase. This may cause data to fail to load"+ " when using tools like go-bindata.") } wg.Wait() return nil } -func parseAliasFile(baseFolder string) map[string]string { +func parseAliasFile(baseFolder string) (map[string]string, error) { aliasFile, err := fileutil.ReadFile(filepath.Join(baseFolder, "alias.json")) aliases := make(map[string]string) if err == nil { err = json.Unmarshal(aliasFile, &aliases) if err != nil { - dlog.Error("Alias file unparseable: ", err) + return nil, oakerr.InvalidInput{InputName: "alias.json"} } } - return aliases + return aliases, nil } func parseLoadFolderName(aliases map[string]string, name string) (int, int, bool, error) { var frameW, frameH int if result := regexpTwoNumbers.Find([]byte(name)); result != nil { vals := strings.Split(string(result), "x") - dlog.Verb("Extracted dimensions: ", vals) frameW, _ = strconv.Atoi(vals[0]) frameH, _ = strconv.Atoi(vals[1]) } else if result := regexpSingleNumber.Find([]byte(name)); result != nil { @@ -140,7 +133,6 @@ func parseLoadFolderName(aliases map[string]string, name string) (int, int, bool if aliased, ok := aliases[name]; ok { if result := regexpTwoNumbers.Find([]byte(aliased)); result != nil { vals := strings.Split(string(result), "x") - dlog.Verb("Extracted dimensions: ", vals) frameW, _ = strconv.Atoi(vals[0]) frameH, _ = strconv.Atoi(vals[1]) } else if result := regexpSingleNumber.Find([]byte(aliased)); result != nil { diff --git a/render/compositeR.go b/render/compositeR.go index 8a5be41b..5dcbc0b9 100644 --- a/render/compositeR.go +++ b/render/compositeR.go @@ -17,7 +17,6 @@ type CompositeR struct { toUndraw []Renderable rs []Renderable predrawLock sync.Mutex - DrawPolygon } // NewCompositeR creates a new CompositeR from a slice of renderables @@ -137,7 +136,7 @@ func (cs *CompositeR) Copy() Stackable { return cs2 } -func (cs *CompositeR) DrawToScreen(world draw.Image, viewPos intgeom.Point2, screenW, screenH int) { +func (cs *CompositeR) DrawToScreen(world draw.Image, viewPos *intgeom.Point2, screenW, screenH int) { realLength := len(cs.rs) for i := 0; i < realLength; i++ { r := cs.rs[i] @@ -158,10 +157,7 @@ func (cs *CompositeR) DrawToScreen(world draw.Image, viewPos intgeom.Point2, scr y += h if x > viewPos[0] && y > viewPos[1] && x2 < viewPos[0]+screenW && y2 < viewPos[1]+screenH { - - if cs.InDrawPolygon(x, y, x2, y2) { - r.Draw(world, float64(-viewPos[0]), float64(-viewPos[1])) - } + r.Draw(world, float64(-viewPos[0]), float64(-viewPos[1])) } } cs.rs = cs.rs[0:realLength] @@ -170,3 +166,24 @@ func (cs *CompositeR) DrawToScreen(world draw.Image, viewPos intgeom.Point2, scr func (cs *CompositeR) Clear() { *cs = *NewCompositeR() } + +// ToSprite converts the composite into a sprite by drawing each layer in order +// and overwriting lower layered pixels +func (cs *CompositeR) ToSprite() *Sprite { + var maxW, maxH int + for _, r := range cs.rs { + x, y := int(r.X()), int(r.Y()) + w, h := r.GetDims() + if x+w > maxW { + maxW = x + w + } + if y+h > maxH { + maxH = y + h + } + } + sp := NewEmptySprite(cs.X(), cs.Y(), maxW, maxH) + for _, r := range cs.rs { + r.Draw(sp, 0, 0) + } + return sp +} diff --git a/render/compositeR_test.go b/render/compositeR_test.go index 662531e9..6250d7fd 100644 --- a/render/compositeR_test.go +++ b/render/compositeR_test.go @@ -35,7 +35,7 @@ func TestCompositeR(t *testing.T) { } cmp.Draw(image.NewRGBA(image.Rect(0, 0, 10, 10)), 0, 0) - cmp.DrawToScreen(image.NewRGBA(image.Rect(0, 0, 3, 3)), intgeom.Point2{0, 0}, 100, 100) + cmp.DrawToScreen(image.NewRGBA(image.Rect(0, 0, 3, 3)), &intgeom.Point2{0, 0}, 100, 100) cmp.Undraw() if cmp.GetLayer() != Undraw { t.Fatalf("composite layer was not set to Undraw when undrawn") @@ -50,7 +50,7 @@ func TestCompositeR(t *testing.T) { t.Fatalf("composite length mismatch post copy") } - cmp2.DrawToScreen(image.NewRGBA(image.Rect(0, 0, 3, 3)), intgeom.Point2{0, 0}, 100, 100) + cmp2.DrawToScreen(image.NewRGBA(image.Rect(0, 0, 3, 3)), &intgeom.Point2{0, 0}, 100, 100) cmp2.Prepend(nil) if cmp2.Len() != 1 { diff --git a/render/debugMap.go b/render/debugMap.go index 71745d8b..a968e440 100644 --- a/render/debugMap.go +++ b/render/debugMap.go @@ -2,6 +2,7 @@ package render import "golang.org/x/sync/syncmap" +// TODO: move this to a debug tools package var ( debugMap syncmap.Map ) @@ -21,3 +22,16 @@ func GetDebugRenderable(rName string) (Renderable, bool) { } return r.(Renderable), ok } + +// EnumerateDebugRenderableKeys which does not check to see if the associated renderables are still extant +func EnumerateDebugRenderableKeys() []string { + keys := []string{} + debugMap.Range(func(k, v interface{}) bool { + key, ok := k.(string) + if ok { + keys = append(keys, key) + } + return true + }) + return keys +} diff --git a/render/default_font.go b/render/default_font.go index e70cdbeb..d62966d6 100644 --- a/render/default_font.go +++ b/render/default_font.go @@ -8,7 +8,6 @@ import ( // a font on the user's machine to import. This is that font. It is embedded into // the Go code to ensure it is not stripped from the code by vendoring, for example. // The file is called luxisr.ttf. -// Todo: consider shipping with a smaller-sized font (would be a breaking change) //go:embed luxisr.ttf var luxisrTTF []byte diff --git a/render/defaultfont.go b/render/defaultfont.go index 24cf8262..cd16f13c 100644 --- a/render/defaultfont.go +++ b/render/defaultfont.go @@ -9,9 +9,9 @@ import ( // so storing the result and calling these functions on the stored Font is // recommended in cases where performance is a concern. -// NewText creates a text element using the default font. -func NewText(str fmt.Stringer, x, y float64) *Text { - return DefaultFont().NewText(str, x, y) +// NewStringerText creates a text element using the default font and a stringer. +func NewStringerText(str fmt.Stringer, x, y float64) *Text { + return DefaultFont().NewStringerText(str, x, y) } // NewIntText wraps the given int pointer in a stringer interface and creates @@ -20,9 +20,9 @@ func NewIntText(str *int, x, y float64) *Text { return DefaultFont().NewIntText(str, x, y) } -// NewStrText is a helper to take in a string instead of a Stringer for NewText -func NewStrText(str string, x, y float64) *Text { - return DefaultFont().NewStrText(str, x, y) +// NewText is a helper to create a text element with the default font and a string. +func NewText(str string, x, y float64) *Text { + return DefaultFont().NewText(str, x, y) } // NewStrPtrText is a helper to take in a string pointer for NewText diff --git a/render/defaultfont_test.go b/render/defaultfont_test.go index d83d8b12..7338d696 100644 --- a/render/defaultfont_test.go +++ b/render/defaultfont_test.go @@ -6,11 +6,11 @@ import ( func TestLegacyFont(t *testing.T) { initTestFont() - if NewText(dummyStringer{}, 0, 0) == nil { - t.Fatalf("NewText failed") + if NewStringerText(dummyStringer{}, 0, 0) == nil { + t.Fatalf("NewStringerText failed") } - if NewStrText("text", 0, 0) == nil { - t.Fatalf("NewStrText failed") + if NewText("text", 0, 0) == nil { + t.Fatalf("NewText failed") } if NewIntText(new(int), 0, 0) == nil { t.Fatalf("NewIntText failed") diff --git a/render/draw.go b/render/draw.go index ebb860c1..2f75ce63 100644 --- a/render/draw.go +++ b/render/draw.go @@ -2,10 +2,6 @@ package render import ( "image/color" - - "time" - - "github.com/oakmound/oak/v3/timing" ) var ( @@ -44,17 +40,3 @@ func DrawColor(c color.Color, x, y, w, h float64, layers ...int) (Renderable, er func DrawPoint(c color.Color, x1, y1 float64, layers ...int) (Renderable, error) { return DrawColor(c, x1, y1, 1, 1, layers...) } - -// DrawForTime draws and after d undraws an element -func DrawForTime(r Renderable, d time.Duration, layers ...int) error { - _, err := Draw(r, layers...) - if err != nil { - return err - } - go func(r Renderable, d time.Duration) { - timing.DoAfter(d, func() { - r.Undraw() - }) - }(r, d) - return nil -} diff --git a/render/drawHeap.go b/render/drawHeap.go index 0fea6a96..84a9d0cd 100644 --- a/render/drawHeap.go +++ b/render/drawHeap.go @@ -15,7 +15,6 @@ type RenderableHeap struct { toUndraw []Renderable static bool addLock sync.RWMutex - DrawPolygon } func newHeap(static bool) *RenderableHeap { @@ -97,22 +96,26 @@ func (rh *RenderableHeap) Copy() Stackable { return newHeap(rh.static) } -func (rh *RenderableHeap) DrawToScreen(world draw.Image, viewPos intgeom.Point2, screenW, screenH int) { +func (rh *RenderableHeap) DrawToScreen(world draw.Image, viewPos *intgeom.Point2, screenW, screenH int) { newRh := &RenderableHeap{} if rh.static { + var r Renderable + // Undraws will all come first, loop to remove them for len(rh.rs) > 0 { - r := rh.heapPop() + r = rh.heapPop() if r.GetLayer() != Undraw { - r.Draw(world, 0, 0) - newRh.heapPush(r) - // TODO: at this point we know r.GetLayer cannot be Undraw - // (because undraws will all come first) - // can we use a smarter data structure that could do: - // 1. pop all undraws and remove them - // 2. iterate through remaining elements in order, drawing - // 3. re-init the tree for layer changes + break } } + for len(rh.rs) > 0 { + r.Draw(world, 0, 0) + newRh.heapPush(r) + r = rh.heapPop() + } + if r != nil && r.GetLayer() != Undraw { + r.Draw(world, 0, 0) + newRh.heapPush(r) + } } else { // TODO: test if we can remove these bounds checks (because draw.Draw already does them) vx := float64(-viewPos[0]) @@ -127,10 +130,7 @@ func (rh *RenderableHeap) DrawToScreen(world draw.Image, viewPos intgeom.Point2, y := h + y2 if x > viewPos[0] && y > viewPos[1] && x2 < viewPos[0]+screenW && y2 < viewPos[1]+screenH { - // TODO v3: consider removing DrawPolygon or moving to oak grove - if rh.InDrawPolygon(x, y, x2, y2) { - r.Draw(world, vx, vy) - } + r.Draw(world, vx, vy) } newRh.heapPush(r) } diff --git a/render/drawHeap_test.go b/render/drawHeap_test.go index 290db57d..9e2d87fd 100644 --- a/render/drawHeap_test.go +++ b/render/drawHeap_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/oakmound/oak/v3/alg/intgeom" - "github.com/oakmound/oak/v3/collision" ) const heapLoops = 2000 @@ -32,9 +31,8 @@ func TestDrawHeapLoop(t *testing.T) { NewColorBox(6, 6, color.RGBA{20, 0, 0, 255}), NewColorBox(7, 7, color.RGBA{40, 0, 0, 255}), NewColorBox(8, 9, color.RGBA{60, 0, 0, 255})), 3}, - {DefaultFont().NewStrText("fire", 15, 15), 5}, + {DefaultFont().NewText("fire", 15, 15), 5}, {DefaultFont().NewIntText(&n, 15, 15), 6}, - {DefaultFont().NewText(collision.NewUnassignedSpace(0, 0, 10, 10), 15, 15), 7}, } for _, a := range toAdds { h.Add(a.r, a.layer) @@ -47,8 +45,8 @@ func TestDrawHeapLoop(t *testing.T) { for i := 0; i < heapLoops; i++ { h.PreDraw() h2.PreDraw() - h.DrawToScreen(world, viewPos, 640, 480) - h2.DrawToScreen(world, viewPos, 640, 480) + h.DrawToScreen(world, &viewPos, 640, 480) + h2.DrawToScreen(world, &viewPos, 640, 480) } } diff --git a/render/drawPolygon.go b/render/drawPolygon.go deleted file mode 100644 index 3a310963..00000000 --- a/render/drawPolygon.go +++ /dev/null @@ -1,138 +0,0 @@ -package render - -import ( - "github.com/oakmound/oak/v3/alg" - "github.com/oakmound/oak/v3/alg/floatgeom" -) - -// A DrawPolygon is used to determine whether elements should be drawn, defining -// a polygonal area for what things should be visible. -type DrawPolygon struct { - usingDrawPolygon bool - drawPolygon []floatgeom.Point2 - dims floatgeom.Rect2 - rectangular bool -} - -// SetPolygon sets the draw polygon and flags that draw functions -// should check for containment in the polygon before drawing elements. -func (dp *DrawPolygon) SetPolygon(poly []floatgeom.Point2) { - dp.usingDrawPolygon = true - dp.dims = floatgeom.NewBoundingRect2(poly...) - dp.drawPolygon = poly - dp.rectangular = isRectangular(poly...) -} - -func isRectangular(pts ...floatgeom.Point2) bool { - last := pts[len(pts)-1] - for _, pt := range pts { - // The last point needs to share an x or y value with this point - if !alg.F64eq(pt.X(), last.X()) && !alg.F64eq(pt.Y(), last.Y()) { - return false - } - last = pt - } - return true -} - -// ClearDrawPolygon will stop checking the set draw polygon for whether elements -// should be drawn to screen. If SetDrawPolygon was not called before this was -// called, this does nothing. -// This may in the future be called at the start of new scenes. -func (dp *DrawPolygon) ClearDrawPolygon() { - dp.usingDrawPolygon = false - dp.dims = floatgeom.Rect2{} - dp.rectangular = false -} - -// DrawPolygonBounds returns the dimensions of this draw polygon, or (0,0)->(0,0) -// if there is no draw polygon in use. -func (dp *DrawPolygon) DrawPolygonBounds() floatgeom.Rect2 { - return dp.dims -} - -// InDrawPolygon returns whether a coordinate and dimension set should be drawn -// given the draw polygon -func (dp *DrawPolygon) InDrawPolygon(xi, yi, x2i, y2i int) bool { - if dp.usingDrawPolygon { - x := float64(xi) - y := float64(yi) - x2 := float64(x2i) - y2 := float64(y2i) - - dx := dp.dims.Min.X() - dy := dp.dims.Min.Y() - dx2 := dp.dims.Max.X() - dy2 := dp.dims.Max.Y() - - dimOverlap := false - if x > dx { - if x < dx2 { - dimOverlap = true - } - } else { - if dx < x2 { - dimOverlap = true - } - } - if y > dy { - if y < dy2 { - dimOverlap = true - } - } else { - if dy < y2 { - dimOverlap = true - } - } - if !dimOverlap { - return false - } - if dp.rectangular { - return true - } - r := floatgeom.NewRect2(x, y, x2, y2) - diags := [][2]floatgeom.Point2{ - { - {r.Min.X(), r.Max.Y()}, - {r.Max.X(), r.Min.Y()}, - }, { - r.Min, - r.Max, - }, - } - last := dp.drawPolygon[len(dp.drawPolygon)-1] - for i := 0; i < len(dp.drawPolygon); i++ { - // Either 1. This point is contained in the renderable box or - // 2. The line segment connecting this point to the last point intersects - // one diagonal of the renderable box. - next := dp.drawPolygon[i] - if r.Contains(next) { - return true - } - // Checking line segment from last to next - for _, diag := range diags { - if orient(diag[0], diag[1], last) != orient(diag[0], diag[1], next) && - orient(next, last, diag[0]) != orient(next, last, diag[1]) { - return true - } - } - - last = next - } - return false - } - return true -} - -func orient(p1, p2, p3 floatgeom.Point2) int8 { - val := (p2.Y()-p1.Y())*(p3.X()-p2.X()) - - (p2.X()-p1.X())*(p3.Y()-p2.Y()) - switch { - case val < 0: - return 2 - case val > 0: - return 1 - default: - return 0 - } -} diff --git a/render/drawPolygon_test.go b/render/drawPolygon_test.go deleted file mode 100644 index c317c839..00000000 --- a/render/drawPolygon_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package render - -import ( - "testing" - - "github.com/oakmound/oak/v3/alg/floatgeom" -) - -func TestDrawPolygon(t *testing.T) { - rh := RenderableHeap{} - - r := rh.DrawPolygonBounds() - if r != (floatgeom.Rect2{Min: floatgeom.Point2{0, 0}, Max: floatgeom.Point2{0, 0}}) { - t.Fatalf("draw polygon was not a zero to zero rectangle") - } - - x := 10.0 - y := 10.0 - x2 := 20.0 - y2 := 20.0 - - pgn := []floatgeom.Point2{{x, y}, {x, y2}, {x2, y2}, {x2, y}} - rh.SetPolygon(pgn) - - r = rh.DrawPolygonBounds() - if r != (floatgeom.Rect2{Min: floatgeom.Point2{x, y}, Max: floatgeom.Point2{x2, y2}}) { - t.Fatalf("draw polygon was not a x,y to x2,y2 rectangle") - } - - type testcase struct { - elems [4]int - shouldSucceed bool - } - - tests := []testcase{ - {[4]int{0, 0, 0, 0}, false}, - {[4]int{0, 0, 30, 30}, true}, - {[4]int{15, 15, 17, 17}, true}, - } - - for _, cas := range tests { - if cas.shouldSucceed != rh.InDrawPolygon(cas.elems[0], cas.elems[1], cas.elems[2], cas.elems[3]) { - t.Fatalf("inDrawPolygon failed") - } - } - - rh.ClearDrawPolygon() - - for _, cas := range tests { - if !rh.InDrawPolygon(cas.elems[0], cas.elems[1], cas.elems[2], cas.elems[3]) { - t.Fatalf("inDrawPolygon with a cleared polygon failed") - } - } - - rh.SetPolygon([]floatgeom.Point2{{ - 0, 0, - }, { - 10, 10, - }, { - 0, 10, - }}) - - tests = []testcase{ - {[4]int{0, 0, 0, 0}, false}, - {[4]int{0, 0, 30, 30}, true}, - {[4]int{15, 15, 17, 17}, false}, - {[4]int{4, 0, 8, 3}, false}, - {[4]int{4, 0, 8, 4}, true}, - } - - for _, cas := range tests { - if cas.shouldSucceed != rh.InDrawPolygon(cas.elems[0], cas.elems[1], cas.elems[2], cas.elems[3]) { - t.Fatalf("inDrawPolygon failed") - } - } -} diff --git a/render/drawStack.go b/render/drawStack.go index bcf04662..9126c94e 100644 --- a/render/drawStack.go +++ b/render/drawStack.go @@ -5,8 +5,6 @@ import ( "github.com/oakmound/oak/v3/alg/intgeom" "github.com/oakmound/oak/v3/oakerr" - - "github.com/oakmound/oak/v3/dlog" ) var ( @@ -27,7 +25,7 @@ type Stackable interface { Add(Renderable, ...int) Renderable Replace(Renderable, Renderable, int) Copy() Stackable - DrawToScreen(draw.Image, intgeom.Point2, int, int) + DrawToScreen(draw.Image, *intgeom.Point2, int, int) Clear() } @@ -52,12 +50,11 @@ func (ds *DrawStack) Clear() { // DrawToScreen on a stack will render its contents to the input buffer, for a screen // of w,h dimensions, from a view point of view. -func (ds *DrawStack) DrawToScreen(world draw.Image, view intgeom.Point2, w, h int) { +func (ds *DrawStack) DrawToScreen(world draw.Image, view *intgeom.Point2, w, h int) { for _, a := range ds.as { // If we had concurrent operations, we'd do it here // in that case each draw call would return to us something // to composite onto the window / world - // TODO v3: add 'DrawConcurrency'? Bake in the background drawing? Benchmark if done a.DrawToScreen(world, view, w, h) } } @@ -85,7 +82,6 @@ func (d *DrawStack) Draw(r Renderable, layers ...int) (Renderable, error) { if len(layers) > 0 { stackLayer := layers[0] if stackLayer < 0 || stackLayer >= len(d.as) { - dlog.Error("Layer", stackLayer, "does not exist on global draw stack") return nil, oakerr.InvalidInput{InputName: "layers"} } return d.as[stackLayer].Add(r, layers[1:]...), nil diff --git a/render/drawStack_test.go b/render/drawStack_test.go index bc864c3c..8ff14adc 100644 --- a/render/drawStack_test.go +++ b/render/drawStack_test.go @@ -43,7 +43,7 @@ func TestDrawStack_Draw(t *testing.T) { Draw(cb) rgba := image.NewRGBA(image.Rect(0, 0, 10, 10)) GlobalDrawStack.PreDraw() - GlobalDrawStack.DrawToScreen(rgba, intgeom.Point2{0, 0}, 10, 10) + GlobalDrawStack.DrawToScreen(rgba, &intgeom.Point2{0, 0}, 10, 10) if !reflect.DeepEqual(rgba, cb.GetRGBA()) { t.Fatalf("rgba mismatch") } diff --git a/render/draw_test.go b/render/draw_test.go index d3274a6b..29182b24 100644 --- a/render/draw_test.go +++ b/render/draw_test.go @@ -50,16 +50,6 @@ func TestDrawHelpers(t *testing.T) { t.Fatalf("draw color to invalid layer should fail") } - err = DrawForTime(NewColorBox(5, 5, color.RGBA{255, 255, 255, 255}), 0, 4) - if err == nil { - t.Fatalf("draw time to invalid layer should fail") - } - - err = DrawForTime(NewColorBox(5, 5, color.RGBA{255, 255, 255, 255}), 0, 0) - if err != nil { - t.Fatalf("draw time should not have failed") - } - _, err = DrawPoint(color.RGBA{100, 100, 100, 255}, 0, 0, 0) if err != nil { t.Fatalf("draw color should not have failed") diff --git a/render/font.go b/render/font.go index 28f129f1..ad5eb193 100644 --- a/render/font.go +++ b/render/font.go @@ -15,7 +15,6 @@ import ( "github.com/oakmound/oak/v3/alg/intgeom" "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/fileutil" - "github.com/oakmound/oak/v3/oakerr" ) var ( @@ -56,6 +55,7 @@ func (fg *FontGenerator) Generate() (*Font, error) { // Replace zero values with defaults var fnt *truetype.Font + var err error if fg.File == "" && len(fg.RawFile) == 0 { if defaultFontFile != "" { fg.File = defaultFontFile @@ -64,7 +64,6 @@ func (fg *FontGenerator) Generate() (*Font, error) { } } if len(fg.RawFile) != 0 { - var err error fnt, err = truetype.Parse(fg.RawFile) if err != nil { return nil, err @@ -74,9 +73,9 @@ func (fg *FontGenerator) Generate() (*Font, error) { if fg.Absolute { dir = "" } - fnt = LoadFont(dir, fg.File) - if fnt == nil { - return nil, oakerr.InvalidInput{InputName: "fg.File"} + fnt, err = LoadFont(dir, fg.File) + if err != nil { + return nil, err } } if fg.Size == 0 { @@ -135,9 +134,15 @@ func (f *Font) Copy() *Font { if f.Unsafe { return f } - f2 := &Font{} - *f2 = *f - f2.mutex = sync.Mutex{} + f2 := &Font{ + FontGenerator: f.FontGenerator, + Drawer: f.Drawer, + ttfnt: f.ttfnt, + bounds: f.bounds, + Unsafe: f.Unsafe, + mutex: sync.Mutex{}, + Fallbacks: f.Fallbacks, + } f2.Drawer.Face = truetype.NewFace(f.ttfnt, &truetype.Options{ Size: f.FontGenerator.Size, DPI: f.FontGenerator.DPI, @@ -146,7 +151,10 @@ func (f *Font) Copy() *Font { return f2 } -// TODO: Implement the below functions manually with font fallback +// TODO: Implement MeasureString manually with font fallback +// This is non-trivial, as we currently detect empty boxes with +// y values which we would not get using the algorithm MeasureString +// calls. func (f *Font) MeasureString(s string) fixed.Int26_6 { f.mutex.Lock() @@ -223,7 +231,7 @@ func parseFontHinting(hintType string) (faceHinting font.Hinting) { hintType = strings.ToLower(hintType) switch hintType { default: - dlog.Error("Unable to parse font hinting, ", hintType) + dlog.Error("Unable to parse font hinting: ", hintType) fallthrough case "", "none": faceHinting = font.HintingNone @@ -250,19 +258,17 @@ func FontColor(s string) image.Image { // LoadFont loads in a font file and stores it with the given fontFile name. // This is necessary before using that file in a generator, otherwise the default // directory will be tried at generation time. -func LoadFont(dir, fontFile string) *truetype.Font { +func LoadFont(dir, fontFile string) (*truetype.Font, error) { if _, ok := loadedFonts[fontFile]; !ok { fontBytes, err := fileutil.ReadFile(filepath.Join(dir, fontFile)) if err != nil { - dlog.Error(err) - return nil + return nil, err } font, err := truetype.Parse(fontBytes) if err != nil { - dlog.Error(err) - return nil + return nil, err } loadedFonts[fontFile] = font } - return loadedFonts[fontFile] + return loadedFonts[fontFile], nil } diff --git a/render/fontManager.go b/render/fontManager.go deleted file mode 100644 index 15fb99b2..00000000 --- a/render/fontManager.go +++ /dev/null @@ -1,41 +0,0 @@ -package render - -import "github.com/oakmound/oak/v3/oakerr" - -// A FontManager is just a map for fonts that contains a default font -type FontManager map[string]*Font - -// NewFontManager returns a FontManager where 'def' is the default font -func NewFontManager() *FontManager { - fm := &FontManager{} - (*fm)["def"], _ = (&FontGenerator{}).Generate() - return fm -} - -// NewFont adds to the font manager and potentially returns if the key -// was already defined in the map -func (fm *FontManager) NewFont(name string, fg FontGenerator) error { - manager := (*fm) - var err error - if _, ok := manager[name]; ok { - err = oakerr.ExistingElement{ - InputName: name, - InputType: "font", - Overwritten: true, - } - } - fnt, genErr := (&fg).Generate() - if genErr != nil { - return genErr - } - manager[name] = fnt - return err - -} - -// Get retrieves a font from a manager -func (fm *FontManager) Get(name string) *Font { - manager := (*fm) - font := manager[name] - return font -} diff --git a/render/fontManager_test.go b/render/fontManager_test.go deleted file mode 100644 index 1c16a9f5..00000000 --- a/render/fontManager_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package render - -import ( - "image" - "testing" - - "github.com/oakmound/oak/v3/oakerr" -) - -func TestFontManager(t *testing.T) { - - initTestFont() - - fm := NewFontManager() - - fm.Get("def") - // That may or may not be nil depending on if this is being run in a -coverprofile test - // or not. Todo: fiddle with fonts and fix it - //assert.NotNil(t, f) - f := fm.Get("other") - if f != nil { - t.Fatalf("other should not be a defined font") - } - - fg := FontGenerator{ - RawFile: luxisrTTF, - Color: image.Black, - } - - err := fm.NewFont("other", fg) - if err != nil { - t.Fatalf("new font should not have failed") - } - - f = fm.Get("other") - if f == nil { - t.Fatalf("other should be a defined font after it was set") - } - - err = fm.NewFont("def", fg) - if err == nil { - t.Fatalf("new font under def name should have errored`") - } - if exists, ok := err.(oakerr.ExistingElement); ok { - if !exists.Overwritten { - t.Fatalf("def should have been overwritten") - } - } -} diff --git a/render/font_test.go b/render/font_test.go index 9133b977..b317d666 100644 --- a/render/font_test.go +++ b/render/font_test.go @@ -18,7 +18,6 @@ func TestFont_UnsafeCopy(t *testing.T) { var initTestFontOnce sync.Once -// Todo: move this to font_test.go, once we have font_test.go func initTestFont() { initTestFontOnce.Do(func() { DefFontGenerator = FontGenerator{RawFile: luxisrTTF} diff --git a/render/loadsheet.go b/render/loadsheet.go index cf65b6d2..855da940 100644 --- a/render/loadsheet.go +++ b/render/loadsheet.go @@ -3,7 +3,6 @@ package render import ( "image" - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/oakerr" ) @@ -30,15 +29,12 @@ func LoadSheet(directory, fileName string, w, h, pad int) (*Sheet, error) { imageLock.RUnlock() if !ok { - dlog.Verb("Missing file in loaded images: ", fileName) rgba, err = loadSprite(directory, fileName, 0) if err != nil { return nil, err } } - dlog.Verb("Loading sheet: ", fileName) - sheet, err := MakeSheet(rgba, w, h, pad) if err != nil { return nil, err @@ -56,15 +52,12 @@ func LoadSheet(directory, fileName string, w, h, pad int) (*Sheet, error) { func MakeSheet(rgba *image.RGBA, w, h, pad int) (*Sheet, error) { if w <= 0 { - dlog.Error("Bad dimensions given to load sheet") return nil, oakerr.InvalidInput{InputName: "w"} } if h <= 0 { - dlog.Error("Bad dimensions given to load sheet") return nil, oakerr.InvalidInput{InputName: "h"} } if pad < 0 { - dlog.Error("Bad pad given to load sheet") return nil, oakerr.InvalidInput{InputName: "pad"} } @@ -87,7 +80,6 @@ func MakeSheet(rgba *image.RGBA, w, h, pad int) (*Sheet, error) { if sheetW < 1 || sheetH < 1 || widthBuffers != sheetW-1 || heightBuffers != sheetH-1 { - dlog.Error("Bad dimensions given to load sheet") return nil, oakerr.InvalidInput{InputName: "w,h"} } @@ -103,7 +95,6 @@ func MakeSheet(rgba *image.RGBA, w, h, pad int) (*Sheet, error) { i++ } - dlog.Verb("Loaded sheet into map") return &sheet, nil } @@ -112,7 +103,6 @@ func MakeSheet(rgba *image.RGBA, w, h, pad int) (*Sheet, error) { // Otherwise it will return the sheet as a 2d array of sprites func GetSheet(fileName string) (*Sheet, error) { sheetLock.RLock() - dlog.Verb(loadedSheets, fileName, loadedSheets[fileName]) sh, ok := loadedSheets[fileName] sheetLock.RUnlock() if !ok { diff --git a/render/loadsprite.go b/render/loadsprite.go index 3c7a95a0..1b0e4e97 100644 --- a/render/loadsprite.go +++ b/render/loadsprite.go @@ -116,7 +116,6 @@ func LoadSprite(directory, fileName string) (*Sprite, error) { } r, err := loadSprite(directory, fileName, 0) if err != nil { - dlog.Error(err) return nil, err } return NewSprite(0, 0, r), nil diff --git a/render/mod/cut.go b/render/mod/cut.go index 23f76d09..8d1f01fc 100644 --- a/render/mod/cut.go +++ b/render/mod/cut.go @@ -10,7 +10,6 @@ import ( ) // CutRound rounds the edges of the Modifiable with Bezier curves. -// Todo: We have a nice bezier toolkit now, so use it here func CutRound(xOff, yOff float64) Mod { return func(rgba image.Image) *image.RGBA { bds := rgba.Bounds() diff --git a/render/mod/filter.go b/render/mod/filter.go index 8a9622d8..f578cfbd 100644 --- a/render/mod/filter.go +++ b/render/mod/filter.go @@ -4,8 +4,6 @@ import ( "image" "image/color" "math" - - "github.com/oakmound/oak/v3/dlog" ) // A Filter modifies an input image in place. This is useful notably for modifying @@ -176,7 +174,6 @@ func StripOuterAlpha(m *image.RGBA, level int) Filter { l := uint8(level) return func(rgba *image.RGBA) { if m == nil { - dlog.Warn("Invalid rgba provided to stripouteralpha") return } diff --git a/render/noopStackable.go b/render/noopStackable.go index 42165d2d..64bdd43d 100644 --- a/render/noopStackable.go +++ b/render/noopStackable.go @@ -26,6 +26,6 @@ func (ns NoopStackable) Copy() Stackable { return ns } -func (ns NoopStackable) DrawToScreen(draw.Image, intgeom.Point2, int, int) {} +func (ns NoopStackable) DrawToScreen(draw.Image, *intgeom.Point2, int, int) {} func (ns NoopStackable) Clear() {} diff --git a/render/noopStackable_test.go b/render/noopStackable_test.go index 85f1e163..5357c611 100644 --- a/render/noopStackable_test.go +++ b/render/noopStackable_test.go @@ -11,7 +11,7 @@ func TestNoopStackable(t *testing.T) { // these calls are noops noop.PreDraw() noop.Replace(nil, nil, -142) - noop.DrawToScreen(nil, intgeom.Point2{}, -124, 23) + noop.DrawToScreen(nil, &intgeom.Point2{}, -124, 23) r := noop.Add(nil, 01, 124, 04, 2) if r != nil { t.Fatalf("expected nil renderable from Add, got %v", r) diff --git a/render/particle/allocator.go b/render/particle/allocator.go index 409cbf2f..600cad0e 100644 --- a/render/particle/allocator.go +++ b/render/particle/allocator.go @@ -6,7 +6,6 @@ const ( blockSize = 2048 ) -// TODO: add .Stop? type Allocator struct { particleBlocks map[int]event.CID nextOpenCh chan int @@ -14,6 +13,7 @@ type Allocator struct { allocCh chan event.CID requestCh chan int responseCh chan event.CID + stopCh chan struct{} } func NewAllocator() *Allocator { @@ -24,6 +24,7 @@ func NewAllocator() *Allocator { allocCh: make(chan event.CID), requestCh: make(chan int), responseCh: make(chan event.CID), + stopCh: make(chan struct{}), } } @@ -32,6 +33,8 @@ func (a *Allocator) Run() { for { if _, ok := a.particleBlocks[lastOpen]; !ok { select { + case <-a.stopCh: + return case pID := <-a.requestCh: a.responseCh <- a.particleBlocks[pID/blockSize] lastOpen-- @@ -45,6 +48,8 @@ func (a *Allocator) Run() { } } select { + case <-a.stopCh: + return case i := <-a.freeCh: opened := a.freereceive(i) if opened < lastOpen { @@ -93,3 +98,9 @@ func (a *Allocator) Lookup(id int) Particle { source := a.LookupSource(id) return source.particles[id%blockSize] } + +// Stop stops the allocator's ongoing Run. Once stopped, allocator may not be reused. +// Stop must not be called more than once. +func (a *Allocator) Stop() { + close(a.stopCh) +} diff --git a/render/particle/source.go b/render/particle/source.go index 85994e27..e1abe60d 100644 --- a/render/particle/source.go +++ b/render/particle/source.go @@ -4,13 +4,9 @@ import ( "math" "time" - "github.com/oakmound/oak/v3/alg/range/intrange" - - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/physics" "github.com/oakmound/oak/v3/render" - "github.com/oakmound/oak/v3/timing" ) const ( @@ -20,19 +16,19 @@ const ( // A Source is used to store and control a set of particles. type Source struct { - render.Layered Generator Generator *Allocator - particles [blockSize]Particle - nextPID int - CID event.CID - pIDBlock int - stackLevel int - EndFunc func() - paused bool - started bool - stopped bool + particles [blockSize]Particle + nextPID int + CID event.CID + pIDBlock int + stackLevel int + EndFunc func() + stopRotateAt time.Time + paused bool + started bool + stopped bool } // NewSource creates a new source @@ -48,6 +44,8 @@ func NewSource(g Generator, stackLevel int) *Source { // Init allows a source to be considered as an entity, and initializes it func (ps *Source) Init() event.CID { CID := event.NextID(ps) + ps.stopRotateAt = time.Now().Add( + time.Duration(ps.Generator.GetBaseGenerator().Duration.Poll()) * time.Millisecond) CID.Bind(event.Enter, rotateParticles) ps.CID = CID ps.pIDBlock = ps.Allocate(ps.CID) @@ -171,30 +169,29 @@ func (ps *Source) addParticles() { ps.particles[ps.nextPID] = p ps.nextPID++ p.SetLayer(ps.Layer(bp.GetPos())) - _, err := render.Draw(p, ps.stackLevel) - dlog.ErrorCheck(err) + render.Draw(p, ps.stackLevel) } } // rotateParticles updates particles over time as long // as a Source is active. -func rotateParticles(id event.CID, nothing interface{}) int { +func rotateParticles(id event.CID, payload interface{}) int { ps := id.E().(*Source) + if ps.stopped { + return 0 + } if !ps.started { - if ps.Generator.GetBaseGenerator().Duration != Inf { - go func(ps *Source, duration intrange.Range) { - timing.DoAfter(time.Duration(duration.Poll())*time.Millisecond, func() { - ps.Stop() - }) - }(ps, ps.Generator.GetBaseGenerator().Duration) - } ps.started = true } if !ps.paused { ps.cycleParticles() ps.addParticles() } + if time.Now().After(ps.stopRotateAt) { + go ps.Stop() + return 0 + } return 0 } @@ -254,7 +251,7 @@ func (ps *Source) ShiftX(x float64) { ps.Generator.ShiftX(x) } -// ShiftY shift's a source's underlying generator (todo: consider if this shoud be composed) +// ShiftY shift's a source's underlying generator func (ps *Source) ShiftY(y float64) { ps.Generator.ShiftY(y) } diff --git a/render/sequence_test.go b/render/sequence_test.go index 492a2737..1a0c4ae4 100644 --- a/render/sequence_test.go +++ b/render/sequence_test.go @@ -22,7 +22,7 @@ func TestSequenceTrigger(t *testing.T) { sq := NewSequence(5, NewColorBox(10, 10, color.RGBA{255, 0, 0, 255}), NewColorBox(10, 10, color.RGBA{0, 255, 0, 255})) - go event.ResolvePending() + go event.ResolveChanges() cid := Dummy{}.Init() sq.SetTriggerID(cid) triggerCh := make(chan struct{}) diff --git a/render/sheet.go b/render/sheet.go index be24f4dd..7818c075 100644 --- a/render/sheet.go +++ b/render/sheet.go @@ -3,7 +3,6 @@ package render import ( "image" - "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/oakerr" ) @@ -43,11 +42,6 @@ func NewSheetSequence(sheet *Sheet, fps float64, frames ...int) (*Sequence, erro mods := make([]Modifiable, len(frames)/2) for i := 0; i < len(frames); i += 2 { if len(sh) <= frames[i] || len(sh[frames[i]]) <= frames[i+1] { - dim2 := len(sh) - 1 - if len(sh) > frames[i] { - dim2 = frames[i] - } - dlog.Error("Frame ", frames[i], frames[i+1], "requested but sheet has dimensions ", len(sh), len(sh[dim2])) return nil, oakerr.InvalidInput{InputName: "Frame requested does not exist "} } mods[i/2] = NewSprite(0, 0, sh[frames[i]][frames[i+1]]) diff --git a/render/sprite_test.go b/render/sprite_test.go index 364298c8..b6c0d366 100644 --- a/render/sprite_test.go +++ b/render/sprite_test.go @@ -15,11 +15,9 @@ var ( // this is excessive for a lot of tests // but it takes away some decision making // and could reveal problems that probably aren't there - // but hey you never know widths = intrange.NewLinear(1, 10) heights = intrange.NewLinear(1, 10) colors = colorrange.NewLinear(color.RGBA{0, 0, 0, 0}, color.RGBA{255, 255, 255, 255}) - seeds = intrange.NewLinear(0, 10000) ) const ( diff --git a/render/text.go b/render/text.go index 7a938c22..65378312 100644 --- a/render/text.go +++ b/render/text.go @@ -16,9 +16,9 @@ type Text struct { d *Font } -// NewText takes in anything that has a String() function and returns a text -// object with the associated font and screen position -func (f *Font) NewText(str fmt.Stringer, x, y float64) *Text { +// NewStringerText creates a renderable text component that will draw the string +// provided by the given stringer each frame. +func (f *Font) NewStringerText(str fmt.Stringer, x, y float64) *Text { return &Text{ LayeredPoint: NewLayeredPoint(x, y, 0), text: str, @@ -36,7 +36,7 @@ func (sip stringerIntPointer) String() string { // NewIntText wraps the given int pointer in a stringer interface func (f *Font) NewIntText(str *int, x, y float64) *Text { - return f.NewText(stringerIntPointer{str}, x, y) + return f.NewStringerText(stringerIntPointer{str}, x, y) } type stringStringer string @@ -45,9 +45,9 @@ func (ss stringStringer) String() string { return string(ss) } -// NewStrText is a helper to take in a string instead of a stringer for NewText -func (f *Font) NewStrText(str string, x, y float64) *Text { - return f.NewText(stringStringer(str), x, y) +// NewText creates a renderable text component with the given string body +func (f *Font) NewText(str string, x, y float64) *Text { + return f.NewStringerText(stringStringer(str), x, y) } type stringPtrStringer struct { @@ -61,9 +61,10 @@ func (sp stringPtrStringer) String() string { return string(*sp.s) } -// NewStrPtrText is a helper to take in a string pointer for NewText +// NewStrPtrText creates a renderable text component with a body matching +// and updating to match the content behind the provided string pointer func (f *Font) NewStrPtrText(str *string, x, y float64) *Text { - return f.NewText(stringPtrStringer{str}, x, y) + return f.NewStringerText(stringPtrStringer{str}, x, y) } func (t *Text) drawWithFont(buff draw.Image, xOff, yOff float64, fnt *Font) { @@ -118,25 +119,17 @@ func (t *Text) SetInt(i int) { t.text = stringStringer(strconv.Itoa(i)) } -// SetIntP takes in an integer pointer that will be drawn at whatever -// the value is behind the pointer when it is drawn -// TODO: (3.0) rename to SetIntPtr -func (t *Text) SetIntP(i *int) { +// SetIntPtr takes in an integer pointer that will draw the integer +// behind the pointer, in base 10, each frame +func (t *Text) SetIntPtr(i *int) { t.text = stringerIntPointer{i} } // StringLiteral returns what text is currently rendering. -// Note this avoids the pretty print addtions that the String function adds. func (t *Text) StringLiteral() string { return t.text.String() } -// Todo: more SetX methods like float, floatP - -func (t *Text) String() string { - return "Text[" + t.text.String() + "]" -} - // Wrap returns the input text split into a list of texts // spread vertically, splitting after each charLimit is reached. // the input vertInc is how much each text in the slice will differ by @@ -152,9 +145,9 @@ func (t *Text) Wrap(charLimit int, vertInc float64) []*Text { vertical := 0.0 for i := range out { if start+charLimit <= len(st) { - out[i] = t.d.NewStrText(st[start:start+charLimit], t.X(), t.Y()+vertical) + out[i] = t.d.NewText(st[start:start+charLimit], t.X(), t.Y()+vertical) } else { - out[i] = t.d.NewStrText(st[start:], t.X(), t.Y()+vertical) + out[i] = t.d.NewText(st[start:], t.X(), t.Y()+vertical) } start += charLimit vertical += vertInc diff --git a/render/text_test.go b/render/text_test.go index 939102b2..85abd6bf 100644 --- a/render/text_test.go +++ b/render/text_test.go @@ -8,7 +8,7 @@ import ( func TestTextFns(t *testing.T) { initTestFont() - txt := DefaultFont().NewStrText("Test", 0, 0) + txt := DefaultFont().NewText("Test", 0, 0) fg := FontGenerator{ RawFile: luxisrTTF, @@ -31,7 +31,7 @@ func TestTextFns(t *testing.T) { } n := 100 - txt.SetIntP(&n) + txt.SetIntPtr(&n) n = 50 if txt.text.String() != "50" { @@ -47,9 +47,6 @@ func TestTextFns(t *testing.T) { if txt.text.String() != "Dummy" { t.Fatalf("text SetText failed") } - if txt.String() != "Text[Dummy]" { - t.Fatalf("text String() failed") - } txts := txt.Wrap(1, 10) if len(txts) != 5 { diff --git a/rng.go b/rng.go deleted file mode 100644 index ae86a69c..00000000 --- a/rng.go +++ /dev/null @@ -1,12 +0,0 @@ -package oak - -import ( - "math/rand" - "time" -) - -// seedRNG seeds math/rand with time.Now, useful for minimal examples -// that would tend to forget to do this. TODO v3: add a way to disable this being called -func seedRNG() { - rand.Seed(time.Now().UTC().UnixNano()) -} diff --git a/scene.go b/scene.go index 40988c47..032cd3f3 100644 --- a/scene.go +++ b/scene.go @@ -7,20 +7,21 @@ import ( "github.com/oakmound/oak/v3/timing" ) -// AddScene is shorthand for oak.SceneMap.AddScene -func (c *Controller) AddScene(name string, s scene.Scene) error { - return c.SceneMap.AddScene(name, s) +// AddScene is shorthand for c.SceneMap.AddScene +func (w *Window) AddScene(name string, s scene.Scene) error { + return w.SceneMap.AddScene(name, s) } -func (c *Controller) sceneTransition(result *scene.Result) { +func (w *Window) sceneTransition(result *scene.Result) { if result.Transition != nil { i := 0 cont := true + frameDelay := timing.FPSToFrameDelay(w.FrameRate) for cont { - cont = result.Transition(c.winBuffer.RGBA(), i) - c.publish() + cont = result.Transition(w.winBuffer.RGBA(), i) + w.publish() i++ - time.Sleep(timing.FPSToFrameDelay(c.FrameRate)) + time.Sleep(frameDelay) } } } diff --git a/scene/context.go b/scene/context.go index 39450d30..e04fa3c5 100644 --- a/scene/context.go +++ b/scene/context.go @@ -22,5 +22,4 @@ type Context struct { CallerMap *event.CallerMap MouseTree *collision.Tree CollisionTree *collision.Tree - // todo: ... } diff --git a/scene/delay.go b/scene/delay.go new file mode 100644 index 00000000..524418c8 --- /dev/null +++ b/scene/delay.go @@ -0,0 +1,46 @@ +package scene + +import ( + "context" + "time" + + "github.com/oakmound/oak/v3/render" +) + +// DoAfter will execute the given function after some duration. When the scene +// ends, DoAfter will exit without calling f. This call blocks until one of those +// conditions is reached. +func (c *Context) DoAfter(d time.Duration, f func()) { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + f() + case <-c.Done(): + } +} + +// DoAfterContext will execute the given function once the passed in context is closed. +// When the scene ends, DoAfterContext will exit without calling f. This call blocks until +// one of those conditions is reached. +func (c *Context) DoAfterContext(ctx context.Context, f func()) { + select { + case <-ctx.Done(): + f() + case <-c.Done(): + } +} + +// DrawForTime draws, and after d, undraws an element +func (c *Context) DrawForTime(r render.Renderable, d time.Duration, layers ...int) error { + _, err := c.DrawStack.Draw(r, layers...) + if err != nil { + return err + } + go func(r render.Renderable, d time.Duration) { + c.DoAfter(d, func() { + r.Undraw() + }) + }(r, d) + return nil +} diff --git a/scene/delay_test.go b/scene/delay_test.go new file mode 100644 index 00000000..96b48aed --- /dev/null +++ b/scene/delay_test.go @@ -0,0 +1,100 @@ +package scene + +import ( + "context" + "image/color" + "testing" + "time" + + "github.com/oakmound/oak/v3/render" +) + +func TestDoAfterCancels(t *testing.T) { + baseCtx, cancel := context.WithCancel(context.Background()) + ctx := &Context{ + Context: baseCtx, + } + triggered := false + go ctx.DoAfter(3*time.Second, func() { + triggered = true + }) + // Wait to make sure the routine started + time.Sleep(1 * time.Second) + cancel() + time.Sleep(3 * time.Second) + if triggered { + t.Fatal("doAfter should not have triggered") + } +} + +func TestDoAfterHappens(t *testing.T) { + baseCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx := &Context{ + Context: baseCtx, + } + triggered := false + go ctx.DoAfter(1*time.Second, func() { + triggered = true + }) + time.Sleep(2 * time.Second) + if !triggered { + t.Fatal("doAfter did not trigger") + } +} + +func TestDoAfterContextCancels(t *testing.T) { + baseCtx, baseCancel := context.WithCancel(context.Background()) + ctx := &Context{ + Context: baseCtx, + } + triggered := false + cancelCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + go ctx.DoAfterContext(cancelCtx, func() { + triggered = true + }) + // Wait to make sure the routine started + time.Sleep(1 * time.Second) + baseCancel() + time.Sleep(3 * time.Second) + if triggered { + t.Fatal("doAfterContext should not have triggered") + } +} + +func TestDoAfterContextHappens(t *testing.T) { + baseCtx, baseCancel := context.WithCancel(context.Background()) + defer baseCancel() + ctx := &Context{ + Context: baseCtx, + } + cancelCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + triggered := false + go ctx.DoAfterContext(cancelCtx, func() { + triggered = true + }) + time.Sleep(2 * time.Second) + if !triggered { + t.Fatal("doAfterContext did not trigger") + } +} + +func TestDrawForTime(t *testing.T) { + baseCtx, baseCancel := context.WithCancel(context.Background()) + defer baseCancel() + ctx := &Context{ + Context: baseCtx, + DrawStack: render.NewDrawStack(render.NewDynamicHeap(), render.NewDynamicHeap()), + } + err := ctx.DrawForTime(render.NewColorBox(5, 5, color.RGBA{255, 255, 255, 255}), 0, 4) + if err == nil { + t.Fatalf("draw time to invalid layer should fail") + } + + err = ctx.DrawForTime(render.NewColorBox(5, 5, color.RGBA{255, 255, 255, 255}), 0, 0) + if err != nil { + t.Fatalf("draw time should not have failed") + } +} diff --git a/scene/map_test.go b/scene/map_test.go index eb370898..91e4c424 100644 --- a/scene/map_test.go +++ b/scene/map_test.go @@ -24,7 +24,7 @@ func TestMap(t *testing.T) { } exists := &oakerr.ExistingElement{} if !errors.As(err, exists) { - t.Fatalf("expected ExistingElement error type, got %+T", err) + t.Fatalf("expected ExistingElement error type, got %T", err) } if exists.InputName != "test" { t.Fatalf("expected error input 'test', got %q", exists.InputName) diff --git a/scene/scene.go b/scene/scene.go index 2a9da8c2..2e7d7420 100644 --- a/scene/scene.go +++ b/scene/scene.go @@ -11,7 +11,7 @@ type Scene struct { // Start is called when a scene begins, including contextual information like // what scene came before this one and a direct reference to clean data structures // for event handling and rendering - Start func(context *Context) + Start func(ctx *Context) // If Loop returns true, the scene will continue // If Loop returns false, End will be called to determine the next scene Loop func() (cont bool) diff --git a/sceneLoop.go b/sceneLoop.go index bd2ac1fd..ae802b9e 100644 --- a/sceneLoop.go +++ b/sceneLoop.go @@ -6,160 +6,145 @@ import ( "github.com/oakmound/oak/v3/alg/intgeom" "github.com/oakmound/oak/v3/dlog" "github.com/oakmound/oak/v3/event" + "github.com/oakmound/oak/v3/oakerr" "github.com/oakmound/oak/v3/scene" - "github.com/oakmound/oak/v3/timing" ) -func (c *Controller) sceneLoop(first string, trackingInputs bool) { - err := c.SceneMap.AddScene("loading", scene.Scene{ - Start: func(*scene.Context) { - // TODO: language - dlog.Info("Loading Scene Init") - }, +// the oak loading scene is a reserved scene +// for preloading assets +const oakLoadingScene = "oak:loading" + +func (w *Window) sceneLoop(first string, trackingInputs bool) { + w.SceneMap.AddScene(oakLoadingScene, scene.Scene{ Loop: func() bool { - return c.startupLoading + return w.startupLoading }, End: func() (string, *scene.Result) { - dlog.Info("Load Complete") - return c.firstScene, &scene.Result{ - NextSceneInput: c.FirstSceneInput, + return w.firstScene, &scene.Result{ + NextSceneInput: w.FirstSceneInput, } }, }) - if err != nil { - // ??? - } var prevScene string result := new(scene.Result) - // TODO: language - dlog.Info("First Scene Start") - - c.drawCh <- struct{}{} - c.drawCh <- struct{}{} - - // TODO: language - dlog.Verb("Draw Channel Activated") + w.drawCh <- struct{}{} + w.drawCh <- struct{}{} - c.firstScene = first + w.firstScene = first - c.SceneMap.CurrentScene = "loading" + w.SceneMap.CurrentScene = oakLoadingScene for { - c.setViewport(intgeom.Point2{0, 0}) - c.useViewBounds = false + w.setViewport(intgeom.Point2{0, 0}) + w.RemoveViewportBounds() - dlog.Info("Scene Start", c.SceneMap.CurrentScene) - scen, ok := c.SceneMap.GetCurrent() + dlog.Info("Scene Start: ", w.SceneMap.CurrentScene) + scen, ok := w.SceneMap.GetCurrent() if !ok { - dlog.Error("Unknown scene", c.SceneMap.CurrentScene) - if c.ErrorScene != "" { - c.SceneMap.CurrentScene = c.ErrorScene - scen, ok = c.SceneMap.GetCurrent() + dlog.Error("Unknown scene: ", w.SceneMap.CurrentScene) + if w.ErrorScene != "" { + w.SceneMap.CurrentScene = w.ErrorScene + scen, ok = w.SceneMap.GetCurrent() if !ok { - panic("error scene not defined in scene map") + go w.exitWithError(oakerr.NotFound{InputName: "ErrorScene"}) + return } } else { - panic("Unknown scene " + c.SceneMap.CurrentScene) + go w.exitWithError(oakerr.NotFound{InputName: "Scene"}) + return } } if trackingInputs { - trackInputChanges() + w.trackInputChanges() } - gctx, cancel := context.WithCancel(context.Background()) - defer cancel() + gctx, cancel := context.WithCancel(w.ParentContext) go func() { - dlog.Info("Starting scene in goroutine", c.SceneMap.CurrentScene) + dlog.Verb("Starting scene in goroutine", w.SceneMap.CurrentScene) scen.Start(&scene.Context{ Context: gctx, PreviousScene: prevScene, SceneInput: result.NextSceneInput, - DrawStack: c.DrawStack, - EventHandler: c.logicHandler, - CallerMap: c.CallerMap, - MouseTree: c.MouseTree, - CollisionTree: c.CollisionTree, - Window: c, + DrawStack: w.DrawStack, + EventHandler: w.logicHandler, + CallerMap: w.CallerMap, + MouseTree: w.MouseTree, + CollisionTree: w.CollisionTree, + Window: w, }) - c.transitionCh <- struct{}{} + w.transitionCh <- struct{}{} }() - c.sceneTransition(result) + w.sceneTransition(result) // Post transition, begin loading animation - dlog.Info("Starting load animation") - c.drawCh <- struct{}{} - dlog.Info("Getting Transition Signal") - <-c.transitionCh - dlog.Info("Resume Drawing") + dlog.Verb("Starting load animation") + w.drawCh <- struct{}{} + dlog.Verb("Getting Transition Signal") + <-w.transitionCh + dlog.Verb("Resume Drawing") // Send a signal to resume (or begin) drawing - c.drawCh <- struct{}{} + w.drawCh <- struct{}{} dlog.Info("Looping Scene") cont := true - dlog.ErrorCheck(c.logicHandler.UpdateLoop(c.FrameRate, c.sceneCh)) + dlog.ErrorCheck(w.logicHandler.UpdateLoop(w.FrameRate, w.sceneCh)) nextSceneOverride := "" for cont { select { - case <-c.sceneCh: + case <-w.ParentContext.Done(): + case <-w.quitCh: + cancel() + return + case <-w.sceneCh: cont = scen.Loop() - case nextSceneOverride = <-c.skipSceneCh: + case nextSceneOverride = <-w.skipSceneCh: cont = false } } cancel() - dlog.Info("Scene End", c.SceneMap.CurrentScene) + dlog.Info("Scene End", w.SceneMap.CurrentScene) // We don't want enterFrames going off between scenes - dlog.ErrorCheck(c.logicHandler.Stop()) - prevScene = c.SceneMap.CurrentScene + dlog.ErrorCheck(w.logicHandler.Stop()) + prevScene = w.SceneMap.CurrentScene // Send a signal to stop drawing - c.drawCh <- struct{}{} - - // Reset any ongoing delays - delayLabel: - for { - select { - case timing.ClearDelayCh <- struct{}{}: - default: - break delayLabel - } - } + w.drawCh <- struct{}{} dlog.Verb("Resetting Engine") // Reset transient portions of the engine // We start by clearing the event bus to // remove most ongoing code - c.logicHandler.Reset() + w.logicHandler.Reset() // We follow by clearing collision areas // because otherwise collision function calls // on non-entities (i.e. particles) can still // be triggered and attempt to access an entity dlog.Verb("Event Bus Reset") - c.CollisionTree.Clear() - c.MouseTree.Clear() - if c.CallerMap == event.DefaultCallerMap { + w.CollisionTree.Clear() + w.MouseTree.Clear() + if w.CallerMap == event.DefaultCallerMap { event.ResetCallerMap() - c.CallerMap = event.DefaultCallerMap + w.CallerMap = event.DefaultCallerMap } else { - c.CallerMap = event.NewCallerMap() + w.CallerMap = event.NewCallerMap() } - c.DrawStack.Clear() - c.DrawStack.PreDraw() + w.DrawStack.Clear() + w.DrawStack.PreDraw() dlog.Verb("Engine Reset") // Todo: Add in customizable loading scene between regular scenes, // In addition to the existing customizable loading renderable? - c.SceneMap.CurrentScene, result = scen.End() + w.SceneMap.CurrentScene, result = scen.End() if nextSceneOverride != "" { - c.SceneMap.CurrentScene = nextSceneOverride + w.SceneMap.CurrentScene = nextSceneOverride } // For convenience, we allow the user to return nil // but it gets translated to an empty result diff --git a/sceneLoop_test.go b/sceneLoop_test.go new file mode 100644 index 00000000..fb48c25b --- /dev/null +++ b/sceneLoop_test.go @@ -0,0 +1,49 @@ +package oak + +import ( + "testing" + "time" + + "github.com/oakmound/oak/v3/scene" +) + +func TestSceneLoopUnknownScene(t *testing.T) { + c1 := NewWindow() + err := c1.SceneMap.AddScene("blank", scene.Scene{}) + if err != nil { + t.Fatalf("Scene Add failed: %v", err) + } + err = c1.Init("bad") + if err == nil { + t.Fatal("expected error from Init on unknown scene") + } +} + +func TestSceneLoopUnknownErrorScene(t *testing.T) { + c1 := NewWindow() + err := c1.SceneMap.AddScene("blank", scene.Scene{}) + if err != nil { + t.Fatalf("Scene Add failed: %v", err) + } + c1.ErrorScene = "bad2" + err = c1.Init("bad") + if err == nil { + t.Fatal("expected error from Init to error scene") + } +} + +func TestSceneLoopErrorScene(t *testing.T) { + c1 := NewWindow() + err := c1.SceneMap.AddScene("blank", scene.Scene{}) + if err != nil { + t.Fatalf("Scene Add failed: %v", err) + } + c1.ErrorScene = "blank" + go func() { + err = c1.Init("bad") + }() + time.Sleep(2 * time.Second) + if err != nil { + t.Fatalf("error transitioning to unknown scene: %v", err) + } +} diff --git a/scene_test.go b/scene_test.go new file mode 100644 index 00000000..c92b7431 --- /dev/null +++ b/scene_test.go @@ -0,0 +1,27 @@ +package oak + +import ( + "testing" + + "github.com/oakmound/oak/v3/scene" +) + +func TestSceneTransition(t *testing.T) { + c1 := NewWindow() + c1.AddScene("1", scene.Scene{ + Start: func(context *scene.Context) { + go context.Window.NextScene() + }, + End: func() (nextScene string, result *scene.Result) { + return "2", &scene.Result{ + Transition: scene.Fade(1, 10), + } + }, + }) + c1.AddScene("2", scene.Scene{ + Start: func(context *scene.Context) { + context.Window.Quit() + }, + }) + c1.Init("1") +} diff --git a/screenFilter.go b/screenFilter.go index 24e145ed..99dee82c 100644 --- a/screenFilter.go +++ b/screenFilter.go @@ -9,20 +9,20 @@ import ( ) // SetPalette tells oak to conform the screen to the input color palette before drawing. -func (c *Controller) SetPalette(palette color.Palette) { - c.SetScreenFilter(mod.ConformToPallete(palette)) +func (w *Window) SetPalette(palette color.Palette) { + w.SetScreenFilter(mod.ConformToPallete(palette)) } // SetScreenFilter will filter the screen by the given modification function prior // to publishing the screen's rgba to be displayed. -func (c *Controller) SetScreenFilter(screenFilter mod.Filter) { - c.prePublish = func(c *Controller, tx screen.Texture) { - screenFilter(c.winBuffer.RGBA()) +func (w *Window) SetScreenFilter(screenFilter mod.Filter) { + w.prePublish = func(w *Window, tx screen.Texture) { + screenFilter(w.winBuffer.RGBA()) } } // ClearScreenFilter resets the draw function to no longer filter the screen before // publishing it to the window. -func (c *Controller) ClearScreenFilter() { - c.prePublish = func(*Controller, screen.Texture) {} +func (w *Window) ClearScreenFilter() { + w.prePublish = func(*Window, screen.Texture) {} } diff --git a/screenOpts.go b/screenOpts.go index f3921f77..7f838481 100644 --- a/screenOpts.go +++ b/screenOpts.go @@ -8,8 +8,8 @@ type fullScreenable interface { // SetFullScreen attempts to set the local oak window to be full screen. // If the window does not support this functionality, it will report as such. -func (c *Controller) SetFullScreen(on bool) error { - if fs, ok := c.windowControl.(fullScreenable); ok { +func (w *Window) SetFullScreen(on bool) error { + if fs, ok := w.windowControl.(fullScreenable); ok { return fs.SetFullScreen(on) } return oakerr.UnsupportedPlatform{ @@ -23,9 +23,9 @@ type movableWindow interface { // MoveWindow sets the position of a window to be x,y and it's dimensions to w,h // If the window does not support being positioned, it will report as such. -func (c *Controller) MoveWindow(x, y, w, h int) error { - if mw, ok := c.windowControl.(movableWindow); ok { - return mw.MoveWindow(int32(x), int32(y), int32(w), int32(h)) +func (w *Window) MoveWindow(x, y, wd, h int) error { + if mw, ok := w.windowControl.(movableWindow); ok { + return mw.MoveWindow(int32(x), int32(y), int32(wd), int32(h)) } return oakerr.UnsupportedPlatform{ Operation: "MoveWindow", @@ -38,8 +38,8 @@ type borderlesser interface { // SetBorderless attempts to set the local oak window to have no border. // If the window does not support this functionaltiy, it will report as such. -func (c *Controller) SetBorderless(on bool) error { - if bs, ok := c.windowControl.(borderlesser); ok { +func (w *Window) SetBorderless(on bool) error { + if bs, ok := w.windowControl.(borderlesser); ok { return bs.SetBorderless(on) } return oakerr.UnsupportedPlatform{ @@ -53,8 +53,8 @@ type topMoster interface { // SetTopMost attempts to set the local oak window to stay on top of other windows. // If the window does not support this functionality, it will report as such. -func (c *Controller) SetTopMost(on bool) error { - if tm, ok := c.windowControl.(topMoster); ok { +func (w *Window) SetTopMost(on bool) error { + if tm, ok := w.windowControl.(topMoster); ok { return tm.SetTopMost(on) } return oakerr.UnsupportedPlatform{ @@ -66,8 +66,8 @@ type titler interface { SetTitle(string) error } -func (c *Controller) SetTitle(title string) error { - if t, ok := c.windowControl.(titler); ok { +func (w *Window) SetTitle(title string) error { + if t, ok := w.windowControl.(titler); ok { return t.SetTitle(title) } return oakerr.UnsupportedPlatform{ @@ -79,8 +79,8 @@ type trayIconer interface { SetTrayIcon(string) error } -func (c *Controller) SetTrayIcon(icon string) error { - if t, ok := c.windowControl.(trayIconer); ok { +func (w *Window) SetTrayIcon(icon string) error { + if t, ok := w.windowControl.(trayIconer); ok { return t.SetTrayIcon(icon) } return oakerr.UnsupportedPlatform{ @@ -92,8 +92,8 @@ type trayNotifier interface { ShowNotification(title, msg string, icon bool) error } -func (c *Controller) ShowNotification(title, msg string, icon bool) error { - if t, ok := c.windowControl.(trayNotifier); ok { +func (w *Window) ShowNotification(title, msg string, icon bool) error { + if t, ok := w.windowControl.(trayNotifier); ok { return t.ShowNotification(title, msg, icon) } return oakerr.UnsupportedPlatform{ @@ -105,11 +105,25 @@ type cursorHider interface { HideCursor() error } -func (c *Controller) HideCursor() error { - if t, ok := c.windowControl.(cursorHider); ok { +func (w *Window) HideCursor() error { + if t, ok := w.windowControl.(cursorHider); ok { return t.HideCursor() } return oakerr.UnsupportedPlatform{ Operation: "HideCursor", } } + +type getCursorPositioner interface { + GetCursorPosition() (x, y float64) +} + +func (w *Window) GetCursorPosition() (x, y float64, err error) { + if wp, ok := w.windowControl.(getCursorPositioner); ok { + x, y := wp.GetCursorPosition() + return x, y, nil + } + return 0, 0, oakerr.UnsupportedPlatform{ + Operation: "GetCursorPosition", + } +} diff --git a/screenOpts_test.go b/screenOpts_test.go new file mode 100644 index 00000000..be8dca28 --- /dev/null +++ b/screenOpts_test.go @@ -0,0 +1,22 @@ +package oak + +import "testing" + +func TestScreenOpts(t *testing.T) { + // What these functions do (and error presence) depends on the operating + // system / build tags, which we can't configure at test time without + // making a new driver just for this test. + c1 := blankScene(t) + c1.SetFullScreen(true) + c1.SetFullScreen(false) + c1.MoveWindow(10, 10, 20, 20) + c1.SetBorderless(true) + c1.SetBorderless(false) + c1.SetTopMost(true) + c1.SetTopMost(false) + c1.SetTitle("testScreenOpts") + c1.SetTrayIcon("icon.ico") + c1.ShowNotification("testnotification", "testmessge", true) + c1.ShowNotification("testnotification", "testmessge", false) + c1.HideCursor() +} diff --git a/screenshot.go b/screenshot.go index b85e15e3..0a1d7efc 100644 --- a/screenshot.go +++ b/screenshot.go @@ -13,13 +13,13 @@ import ( // ScreenShot takes a snap shot of the window's image content. // ScreenShot is not safe to call while an existing ScreenShot call has // yet to finish executing. This could change in the future. -func (c *Controller) ScreenShot() *image.RGBA { +func (w *Window) ScreenShot() *image.RGBA { shotCh := make(chan *image.RGBA) // We need to take the shot when the screen is not being redrawn // We know the screen has everything drawn on it when it is published - c.prePublish = func(c *Controller, tx screen.Texture) { + w.prePublish = func(w *Window, tx screen.Texture) { // Copy the buffer - rgba := c.winBuffer.RGBA() + rgba := w.winBuffer.RGBA() bds := rgba.Bounds() copy := image.NewRGBA(bds) for x := bds.Min.X; x < bds.Max.X; x++ { @@ -30,32 +30,32 @@ func (c *Controller) ScreenShot() *image.RGBA { shotCh <- copy } out := <-shotCh - c.ClearScreenFilter() + w.ClearScreenFilter() return out } // gifShot is internally used by RecordGIF -func (c *Controller) gifShot() *image.Paletted { +func (w *Window) gifShot() *image.Paletted { shotCh := make(chan *image.Paletted) // We need to take the shot when the screen is not being redrawn // We know the screen has everything drawn on it when it is published - c.prePublish = func(c *Controller, tx screen.Texture) { + w.prePublish = func(w *Window, tx screen.Texture) { // Copy the buffer - rgba := c.winBuffer.RGBA() + rgba := w.winBuffer.RGBA() bds := rgba.Bounds() copy := image.NewPaletted(bds, palette.Plan9) draw.Draw(copy, bds, rgba, zeroPoint, draw.Src) shotCh <- copy } out := <-shotCh - c.ClearScreenFilter() + w.ClearScreenFilter() return out } // RecordGIF will start recording frames via screen shots with the given // time delay (in 1/100ths of a second) between frames. When the returned // stop function is called, the frames will be compiled into a gif. -func (c *Controller) RecordGIF(hundredths int) (stop func() *gif.GIF) { +func (w *Window) RecordGIF(hundredths int) (stop func() *gif.GIF) { cancel := make(chan struct{}) out := make(chan *gif.GIF) delay := time.Duration(hundredths) * time.Millisecond * 10 @@ -68,7 +68,7 @@ func (c *Controller) RecordGIF(hundredths int) (stop func() *gif.GIF) { out <- g return } - shot := c.gifShot() + shot := w.gifShot() g.Image = append(g.Image, shot) g.Delay = append(g.Delay, hundredths) } diff --git a/screenshot_test.go b/screenshot_test.go new file mode 100644 index 00000000..7c38ae9d --- /dev/null +++ b/screenshot_test.go @@ -0,0 +1,67 @@ +package oak + +import ( + "image" + "os" + "path/filepath" + "testing" + "time" + + "github.com/oakmound/oak/v3/scene" +) + +func blankScene(t *testing.T) *Window { + t.Helper() + c1 := NewWindow() + err := c1.SceneMap.AddScene("blank", scene.Scene{}) + if err != nil { + t.Fatalf("Scene Add failed: %v", err) + } + go c1.Init("blank") + time.Sleep(2 * time.Second) + return c1 +} + +func TestRecordGIF(t *testing.T) { + c1 := blankScene(t) + stop := c1.RecordGIF(100) + time.Sleep(2 * time.Second) + stop() + // TODO: could test that the gif has expected contents +} + +func TestScreenShot(t *testing.T) { + c1 := blankScene(t) + MatchScreenShot(t, c1, filepath.Join("testdata", "screenshot.png")) +} + +func MatchScreenShot(t *testing.T, w *Window, path string) { + t.Helper() + rgba := w.ScreenShot() + f, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open screenshot file: %v", err) + } + testRGBA, _, err := image.Decode(f) + if err != nil { + t.Fatalf("failed to decode screenshot file: %v", err) + } + bds := rgba.Bounds() + if testRGBA.Bounds() != bds { + t.Fatalf("mismatch screenshot size: got %v expected %v", bds, testRGBA.Bounds()) + } + for x := bds.Min.X; x < bds.Max.X; x++ { + for y := bds.Min.Y; y < bds.Max.Y; y++ { + got := rgba.RGBAAt(x, y) + gotR, gotG, gotB, gotA := got.RGBA() + testGot := testRGBA.At(x, y) + testR, testG, testB, testA := testGot.RGBA() + if gotR != testR || + gotG != testG || + gotB != testB || + gotA != testA { + t.Fatalf("pixel mismatch (%d,%d)", x, y) + } + } + } +} diff --git a/shake.go b/shake.go deleted file mode 100644 index a92709aa..00000000 --- a/shake.go +++ /dev/null @@ -1,69 +0,0 @@ -package oak - -import ( - "math/rand" - "time" - - "github.com/oakmound/oak/v3/alg/floatgeom" - "github.com/oakmound/oak/v3/alg/intgeom" -) - -// TODO: Shakers don't need to be screen-dependant-- they just need something with -// a ShiftPos function. -// TODO: Shakers should accept a speed, so they aren't just moving as fast as possible - -// A ScreenShaker knows how to shake a screen by a (or up to a) given magnitude. -// If Random is true, the Shaker will shake up to the (negative or positive) -// magnitude of each the X and Y axes. Otherwise, it will oscillate between -// negative magnitude and positive magnitude. -type ScreenShaker struct { - Random bool - Magnitude floatgeom.Point2 -} - -var ( - // DefaultShaker is the global default shaker, used when ShakeScreen is called. - DefaultShaker = &ScreenShaker{false, floatgeom.Point2{1.0, 1.0}} -) - -// ShakeScreen will Shake using the package global DefaultShaker -func (c *Controller) ShakeScreen(dur time.Duration) { - c.Shake(DefaultShaker, dur) -} - -// Shake shakes the screen based on this ScreenShaker's attributes. -// See DefaultShaker for an example shaker setup -func (c *Controller) Shake(ss *ScreenShaker, dur time.Duration) { - doneTime := time.Now().Add(dur) - mag := ss.Magnitude - delta := intgeom.Point2{} - - if ss.Random { - randOff := mag - go func() { - - for time.Now().Before(doneTime) { - xDelta := int(randOff.X()) - yDelta := int(randOff.Y()) - c.ShiftScreen(xDelta-delta.X(), yDelta-delta.Y()) - delta = intgeom.Point2{xDelta, yDelta} - mag = mag.MulConst(-1) - randOff = mag.MulConst(rand.Float64()) - } - c.ShiftScreen(-delta.X(), -delta.Y()) - }() - } else { - go func() { - - for time.Now().Before(doneTime) { - xDelta := int(mag.X()) - yDelta := int(mag.Y()) - - c.ShiftScreen(xDelta, yDelta) - delta = delta.Add(intgeom.Point2{xDelta, yDelta}) - mag = mag.MulConst(-1) - } - c.ShiftScreen(-delta.X(), -delta.Y()) - }() - } -} diff --git a/shape/bezier_test.go b/shape/bezier_test.go index 4d6dff98..6fb9cec3 100644 --- a/shape/bezier_test.go +++ b/shape/bezier_test.go @@ -35,7 +35,7 @@ func TestBezierCurve(t *testing.T) { } bp, ok := b.(BezierPoint) if !ok { - t.Fatalf("expected BezierPoint, got %+t", b) + t.Fatalf("expected BezierPoint, got %T", b) } expectedBP := BezierPoint{x, y} if bp != expectedBP { @@ -56,12 +56,12 @@ func TestBezierCurve(t *testing.T) { } bn, ok := b.(BezierNode) if !ok { - t.Fatalf("expected BezierNode, got %+T", b) + t.Fatalf("expected BezierNode, got %T", b) } expectedLeft := BezierPoint{x1, y1} left, ok := bn.Left.(BezierPoint) if !ok { - t.Fatalf("expected left of bezier to be BezierNode, got %+T", bn.Left) + t.Fatalf("expected left of bezier to be BezierNode, got %T", bn.Left) } if left != expectedLeft { t.Fatalf("expected left point %+v, got %+v", expectedLeft, left) @@ -69,7 +69,7 @@ func TestBezierCurve(t *testing.T) { expectedRight := BezierPoint{x2, y2} right, ok := bn.Right.(BezierPoint) if !ok { - t.Fatalf("expected right of bezier to be BezierNode, got %+t", bn.Right) + t.Fatalf("expected right of bezier to be BezierNode, got %T", bn.Right) } if right != expectedRight { t.Fatalf("expected right point %+v, got %+v", expectedRight, right) @@ -163,7 +163,7 @@ func TestBezierCurveErrors(t *testing.T) { } insufficient := &oakerr.InsufficientInputs{} if !errors.As(err, insufficient) { - t.Fatalf("expected insufficient error, got %+t", err) + t.Fatalf("expected insufficient error, got %T", err) } if insufficient.AtLeast != 2 { t.Fatalf("expected at least to be '2', got %v", insufficient.AtLeast) @@ -180,7 +180,7 @@ func TestBezierCurveErrors(t *testing.T) { _, err := BezierCurve(floats...) indivisible := &oakerr.IndivisibleInput{} if !errors.As(err, indivisible) { - t.Fatalf("expected indivisible error, got %+t", err) + t.Fatalf("expected indivisible error, got %T", err) } if indivisible.MustDivideBy != 2 { t.Fatalf("expected must divide by to be '2', got %v", indivisible.MustDivideBy) diff --git a/shiny/driver/driver_darwin.go b/shiny/driver/driver_darwin.go index 01e1de12..7ce3e4f2 100644 --- a/shiny/driver/driver_darwin.go +++ b/shiny/driver/driver_darwin.go @@ -4,7 +4,7 @@ // +build darwin // +build darwingl -// +build !noop +// +build !nooswindow package driver diff --git a/shiny/driver/driver_fallback.go b/shiny/driver/driver_fallback.go index b57f1161..50822ee7 100644 --- a/shiny/driver/driver_fallback.go +++ b/shiny/driver/driver_fallback.go @@ -7,7 +7,7 @@ // +build !windows // +build !dragonfly // +build !openbsd -// +build !noop +// +build !nooswindow package driver diff --git a/shiny/driver/driver_noop.go b/shiny/driver/driver_noop.go index d37e8f1a..38516784 100644 --- a/shiny/driver/driver_noop.go +++ b/shiny/driver/driver_noop.go @@ -1,4 +1,4 @@ -// +build noop +// +build nooswindow package driver diff --git a/shiny/driver/driver_windows.go b/shiny/driver/driver_windows.go index 71dfeceb..e527b0f1 100644 --- a/shiny/driver/driver_windows.go +++ b/shiny/driver/driver_windows.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// +build !noop +// +build !nooswindow package driver diff --git a/shiny/driver/driver_x11.go b/shiny/driver/driver_x11.go index 0eeb0cf9..c2169238 100644 --- a/shiny/driver/driver_x11.go +++ b/shiny/driver/driver_x11.go @@ -3,7 +3,7 @@ // license that can be found in the LICENSE file. // +build linux,!android dragonfly openbsd -// +build !noop +// +build !nooswindow package driver diff --git a/shiny/driver/internal/win32/win32.go b/shiny/driver/internal/win32/win32.go index d1b07725..d73172ea 100644 --- a/shiny/driver/internal/win32/win32.go +++ b/shiny/driver/internal/win32/win32.go @@ -383,11 +383,33 @@ func AddWindowMsg(fn func(hwnd HWND, uMsg uint32, wParam, lParam uintptr)) uint3 return uMsg } +// src: https://wiki.winehq.org/List_Of_Windows_Messages +// var unusedMessages = map[uint32]string{ +// 2: "DESTROY", +// 6: "ACTIVATE", +// 28: "ACTIVATE_APP", +// 32: "SETCURSOR", +// 70: "WINDOWPOSCHANGING", +// 130: "NCDESTROY", +// 132: "NCHITTEST", +// 134: "NCACTIVATE", +// 144: "", // we get this, but its not documented in the source list +// 160: "NCMOUSEMOVE", +// 161: "NCLBUTTONDOWN", +// 273: "COMMAND", +// 274: "SYSCOMMAND", +// 533: "CAPTURECHANGED", +// 641: "IME_SETCONTEXT", +// 642: "IME_NOTIFY", +// 674: "NCMOUSELEAVE", +// } + func windowWndProc(hwnd HWND, uMsg uint32, wParam uintptr, lParam uintptr) (lResult uintptr) { fn := windowMsgs[uMsg] if fn != nil { return fn(hwnd, uMsg, wParam, lParam) } + //fmt.Printf("unused message %d, 0x%x, %v\n", uMsg, uMsg, unusedMessages[uMsg]) lResult, _ = DefWindowProc(hwnd, uMsg, wParam, lParam) return lResult } diff --git a/shiny/driver/internal/win32/zsyscall_windows.go b/shiny/driver/internal/win32/zsyscall_windows.go index b8742cc5..26814887 100644 --- a/shiny/driver/internal/win32/zsyscall_windows.go +++ b/shiny/driver/internal/win32/zsyscall_windows.go @@ -43,7 +43,6 @@ var ( var ( procShell_NotifyIconW = modshell32.NewProc("Shell_NotifyIconW") - procRegisterClass = moduser32.NewProc("RegisterClassW") procIsZoomed = moduser32.NewProc("IsZoomed") procLoadIcon = moduser32.NewProc("LoadIconW") @@ -77,6 +76,7 @@ var ( procLoadCursorFromFile = moduser32.NewProc("LoadCursorFromFileW") procCreateCursor = moduser32.NewProc("CreateCursor") procSetClassLongPtr = moduser32.NewProc("SetClassLongPtrW") + procGetCursorPos = moduser32.NewProc("GetCursorPos") ) func _GetKeyboardLayout(threadID uint32) (locale syscall.Handle) { @@ -415,3 +415,9 @@ const ( // Replaces the pointer to the window procedure associated with the class. GCLP_WNDPROC ClassLongParam = -24 ) + +func GetCursorPos() (x, y int, ok bool) { + pt := POINT{} + ret, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) + return int(pt.X), int(pt.Y), ret != 0 +} diff --git a/shiny/driver/mtldriver/mtldriver.go b/shiny/driver/mtldriver/mtldriver.go index 660e3de4..c62a6231 100644 --- a/shiny/driver/mtldriver/mtldriver.go +++ b/shiny/driver/mtldriver/mtldriver.go @@ -88,6 +88,7 @@ func main(f func(screen.Screen)) error { case req := <-moveWindowCh: req.window.SetPos(int(req.x), int(req.y)) req.window.SetSize(int(req.width), int(req.height)) + req.respCh <- struct{}{} default: glfw.WaitEvents() } @@ -106,6 +107,7 @@ type newWindowResp struct { type moveWindowReq struct { window *glfw.Window x, y, width, height int + respCh chan struct{} } type releaseWindowReq struct { @@ -130,6 +132,9 @@ func newWindow(device mtl.Device, releaseWindowCh chan releaseWindowReq, moveWin cv := appkit.NewWindow(unsafe.Pointer(window.GetCocoaWindow())).ContentView() cv.SetLayer(ml) cv.SetWantsLayer(true) + if opts.Borderless { + window.SetAttrib(glfw.Decorated, 0) + } w := &windowImpl{ device: device, diff --git a/shiny/driver/mtldriver/window.go b/shiny/driver/mtldriver/window.go index 9491f218..99d800e5 100644 --- a/shiny/driver/mtldriver/window.go +++ b/shiny/driver/mtldriver/window.go @@ -41,16 +41,24 @@ type windowImpl struct { } func (w *windowImpl) MoveWindow(x, y, width, height int32) error { + respCh := make(chan struct{}) w.moveWindowCh <- moveWindowReq{ window: w.window, x: int(x), y: int(y), width: int(width), height: int(height), + respCh: respCh, } + glfw.PostEmptyEvent() + <-respCh return nil } +func (w *windowImpl) GetCursorPosition() (x, y float64) { + return w.window.GetCursorPos() +} + func (w *windowImpl) Release() { respCh := make(chan struct{}) w.releaseWindowCh <- releaseWindowReq{ diff --git a/shiny/driver/windriver/window.go b/shiny/driver/windriver/window.go index f22f6cca..0062213a 100644 --- a/shiny/driver/windriver/window.go +++ b/shiny/driver/windriver/window.go @@ -494,3 +494,9 @@ func handleCmd(hwnd win32.HWND, uMsg uint32, wParam, lParam uintptr) { c.err = fmt.Errorf("unknown command id=%d", c.id) } } + +func (w *windowImpl) GetCursorPosition() (x, y float64) { + w.windowRect, _ = win32.GetWindowRect(w.hwnd) + xint, yint, _ := win32.GetCursorPos() + return float64(xint) - float64(w.windowRect.Left), float64(yint) - float64(w.windowRect.Top) +} diff --git a/shiny/driver/x11driver/screen.go b/shiny/driver/x11driver/screen.go index 2139e24e..fa15a388 100644 --- a/shiny/driver/x11driver/screen.go +++ b/shiny/driver/x11driver/screen.go @@ -233,6 +233,7 @@ func (s *screenImpl) handleSecondLayerEvent(ev xgb.Event) { if w := s.findWindow(ev.Window); w != nil { w.lifecycler.SetDead(true) w.lifecycler.SendEvent(w, nil) + w.Release() } case s.atoms["WM_TAKE_FOCUS"]: xproto.SetInputFocus(s.xc, xproto.InputFocusParent, ev.Window, xproto.Timestamp(ev.Data.Data32[1])) diff --git a/shiny/driver/x11driver/x11driver.go b/shiny/driver/x11driver/x11driver.go index f3dcd1e4..fa1f63db 100644 --- a/shiny/driver/x11driver/x11driver.go +++ b/shiny/driver/x11driver/x11driver.go @@ -12,6 +12,7 @@ package x11driver import ( "fmt" + "sync" "github.com/BurntSushi/xgb/render" "github.com/BurntSushi/xgb/shm" @@ -34,6 +35,8 @@ func Main(f func(screen.Screen)) { } } +var mainLock sync.Mutex + func main(f func(screen.Screen)) (retErr error) { xutil, err := xgbutil.NewConn() if err != nil { @@ -45,12 +48,14 @@ func main(f func(screen.Screen)) (retErr error) { } }() + mainLock.Lock() if err := render.Init(xutil.Conn()); err != nil { return fmt.Errorf("x11driver: render.Init failed: %v", err) } if err := shm.Init(xutil.Conn()); err != nil { return fmt.Errorf("x11driver: shm.Init failed: %v", err) } + mainLock.Unlock() s, err := newScreenImpl(xutil) if err != nil { diff --git a/testdata/audio/placeholder.txt b/testdata/audio/placeholder.txt new file mode 100644 index 00000000..e69de29b diff --git a/testdata/default.config b/testdata/default.config index 3c6ea84f..90a7eddb 100644 --- a/testdata/default.config +++ b/testdata/default.config @@ -27,6 +27,7 @@ }, "frameRate": 60, "drawFrameRate": 60, + "idleDrawFrameRate": 60, "language": "English", "title": "Oak Window", "batchLoad": false, diff --git a/testdata/images/placeholder.txt b/testdata/images/placeholder.txt new file mode 100644 index 00000000..e69de29b diff --git a/testdata/screenshot.png b/testdata/screenshot.png new file mode 100644 index 00000000..4cfd17c4 Binary files /dev/null and b/testdata/screenshot.png differ diff --git a/timing/delay.go b/timing/delay.go deleted file mode 100644 index f9952fcd..00000000 --- a/timing/delay.go +++ /dev/null @@ -1,33 +0,0 @@ -package timing - -import ( - "context" - "time" -) - -var ( - // ClearDelayCh is used to stop all ongoing delays. It should not be closed. - ClearDelayCh = make(chan struct{}) -) - -// DoAfter wraps time calls in a select that will stop events from happening -// when ClearDelayCh pulls -func DoAfter(d time.Duration, f func()) { - t := time.NewTimer(d) - defer t.Stop() - select { - case <-t.C: - f() - case <-ClearDelayCh: - } -} - -// DoAfterContext executes the function if the context is completed. -// Clears out if the Delay Channel is cleared. -func DoAfterContext(ctx context.Context, f func()) { - select { - case <-ctx.Done(): - f() - case <-ClearDelayCh: - } -} diff --git a/timing/delay_test.go b/timing/delay_test.go deleted file mode 100644 index 25e55a23..00000000 --- a/timing/delay_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package timing - -import ( - "context" - "testing" - "time" -) - -func TestDoAfterCancels(t *testing.T) { - triggered := false - go DoAfter(3*time.Second, func() { - triggered = true - }) - // Wait to make sure the routine started - time.Sleep(1 * time.Second) -outer: - for { - select { - case ClearDelayCh <- struct{}{}: - default: - break outer - } - } - time.Sleep(3 * time.Second) - if triggered { - t.Fatal("doAfter triggered") - } -} - -func TestDoAfterHappens(t *testing.T) { - triggered := false - go DoAfter(1*time.Second, func() { - triggered = true - }) - time.Sleep(2 * time.Second) - if !triggered { - t.Fatal("doAfter did not trigger") - } -} - -func TestDoAfterContextCancels(t *testing.T) { - triggered := false - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - go DoAfterContext(ctx, func() { - triggered = true - }) - // Wait to make sure the routine started - time.Sleep(1 * time.Second) -outer: - for { - select { - case ClearDelayCh <- struct{}{}: - default: - break outer - } - } - time.Sleep(3 * time.Second) - if triggered { - t.Fatal("doAfterContext triggered") - } -} - -func TestDoAfterContextHappens(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - triggered := false - go DoAfterContext(ctx, func() { - triggered = true - }) - time.Sleep(2 * time.Second) - if !triggered { - t.Fatal("doAfterContext did not trigger") - } -} diff --git a/timing/dynamicTicker.go b/timing/dynamicTicker.go deleted file mode 100644 index c97e7159..00000000 --- a/timing/dynamicTicker.go +++ /dev/null @@ -1,117 +0,0 @@ -package timing - -import ( - "time" - - "github.com/oakmound/oak/v3/dlog" -) - -// A DynamicTicker is a ticker which can -// be sent signals in the form of durations to -// change how often it ticks. -type DynamicTicker struct { - ticker *time.Ticker - C chan time.Time - resetCh chan time.Duration - forceTick chan bool -} - -// NewDynamicTicker returns a null-initialized -// dynamic ticker. -func NewDynamicTicker() *DynamicTicker { - dt := &DynamicTicker{ - ticker: time.NewTicker(1000 * time.Hour), - C: make(chan time.Time), - resetCh: make(chan time.Duration), - forceTick: make(chan bool), - } - go func() { - for dt.loop() { - } - }() - return dt -} - -func (dt *DynamicTicker) loop() bool { - select { - case at := <-dt.ticker.C: - return dt.send(at) - case tickDuration := <-dt.resetCh: - dt.ticker.Stop() - dt.ticker = time.NewTicker(tickDuration) - case code := <-dt.forceTick: - if code == forceStop { - dt.close() - return false - } - return dt.send(time.Time{}) - } - return true -} - -func (dt *DynamicTicker) send(v time.Time) bool { - for { - select { - case r := <-dt.forceTick: - if r == forceStop { - dt.close() - return false - } - continue - case tickDuration := <-dt.resetCh: - dt.ticker.Stop() - dt.ticker = time.NewTicker(tickDuration) - return true - case dt.C <- v: - return true - } - } -} - -// SetTick changes the rate at which a dynamic ticker -// ticks -func (dt *DynamicTicker) SetTick(tickDuration time.Duration) { - dt.resetCh <- tickDuration -} - -func (dt *DynamicTicker) close() { - close(dt.C) - close(dt.resetCh) - close(dt.forceTick) -} - -const ( - forceTickOn = true - forceStop = false -) - -// Step will force the dynamic ticker to tick, once. -// If the forced tick is not received, successive calls -// to Step will do nothing. -func (dt *DynamicTicker) Step() { - select { - case dt.forceTick <- forceTickOn: - default: - } -} - -// ForceStep is the blocking equivalent to Step. After -// this is called, it won't return until the ticker has -// taken the forced step through. A potential use for this -// is in benchmarking how often the work between ticks -// can get done. -func (dt *DynamicTicker) ForceStep() { - dt.forceTick <- forceTickOn -} - -// Stop closes all internal channels and stops dt's internal ticker -func (dt *DynamicTicker) Stop() { - defer func() { - if x := recover(); x != nil { - dlog.Error("Dynamic Ticker stopped twice") - } - }() - dt.ticker.Stop() - dt.forceTick <- forceStop - <-dt.forceTick -} diff --git a/timing/dynamic_test.go b/timing/dynamic_test.go deleted file mode 100644 index b323b6a4..00000000 --- a/timing/dynamic_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package timing - -import ( - "testing" - "time" -) - -func TestDynamicTickerFns(t *testing.T) { - t.Parallel() - dt := NewDynamicTicker() - time.Sleep(10 * time.Second) - select { - case <-dt.C: - t.Fatal("Dynamic Ticker should not initially send") - default: - } - dt.Step() - nextTime := <-dt.C - // The above just needs to not time out - now := time.Now() - dt.SetTick(1 * time.Second) - nextTime = <-dt.C - got := nextTime.Sub(now) - expectedLessThan := 1100 * time.Millisecond - if got >= expectedLessThan { - t.Fatalf("expected less than %v, got %v", expectedLessThan, got) - } - dt.Stop() - select { - case _, ok := <-dt.C: - if ok { - t.Fatal("Dynamic Ticker failed to stop") - } - default: - } - - dt = NewDynamicTicker() - dt.SetTick(1 * time.Second) - time.Sleep(2 * time.Second) - dt.Step() - dt.SetTick(2 * time.Second) - dt.ForceStep() -} - -func TestDynamicTickerStop(t *testing.T) { - t.Parallel() - - dt := NewDynamicTicker() - dt.Stop() - - // Successive stops - dt = NewDynamicTicker() - dt.Step() - dt.Stop() - dt.Stop() - - // Unconsumed tick -> stop - dt = NewDynamicTicker() - time.Sleep(1 * time.Second) - dt.SetTick(1 * time.Millisecond) - time.Sleep(2 * time.Second) - dt.Stop() - - // Unconsumed step -> stop - dt = NewDynamicTicker() - dt.SetTick(1 * time.Millisecond) - time.Sleep(1 * time.Second) - dt.SetTick(2 * time.Millisecond) - dt.Step() - dt.Stop() - - // Successive steps - dt = NewDynamicTicker() - time.Sleep(1 * time.Second) - for i := 0; i < 20; i++ { - dt.Step() - time.Sleep(30 * time.Millisecond) - } - dt.Stop() - - // Unconsumed step -> Set Tick - dt = NewDynamicTicker() - time.Sleep(1 * time.Second) - dt.Step() - time.Sleep(1 * time.Second) - dt.SetTick(1 * time.Millisecond) -} diff --git a/timing/fps_test.go b/timing/fps_test.go index 2633cd50..f5a391fe 100644 --- a/timing/fps_test.go +++ b/timing/fps_test.go @@ -80,3 +80,27 @@ func TestFPSToFrameDelay(t *testing.T) { } }) } + +func TestFrameDelayToFPS(t *testing.T) { + t.Parallel() + rand.Seed(time.Now().UnixNano()) + t.Run("1-100001", func(t *testing.T) { + t.Parallel() + for i := 0; i < randTestCt; i++ { + rate := rand.Intn(100000) + 1 + got := FrameDelayToFPS(time.Duration(rate)) + expected := float64(time.Second) / float64(rate) + if got != expected { + t.Fatalf("got fps of %v, expected %v", got, expected) + } + } + }) + t.Run("0", func(t *testing.T) { + t.Parallel() + got := FrameDelayToFPS(0) + expected := math.MaxFloat64 + if got != expected { + t.Fatalf("got fps of %v, expected %v", got, expected) + } + }) +} diff --git a/viewport.go b/viewport.go index ac0378e2..eff21cec 100644 --- a/viewport.go +++ b/viewport.go @@ -6,62 +6,62 @@ import ( ) // SetScreen positions the viewport to be at x,y -func (c *Controller) SetScreen(x, y int) { - c.setViewport(intgeom.Point2{x, y}) +func (w *Window) SetScreen(x, y int) { + w.setViewport(intgeom.Point2{x, y}) } // ShiftScreen shifts the viewport by x,y -func (c *Controller) ShiftScreen(x, y int) { - c.setViewport(c.viewPos.Add(intgeom.Point2{x, y})) +func (w *Window) ShiftScreen(x, y int) { + w.setViewport(w.viewPos.Add(intgeom.Point2{x, y})) } -func (c *Controller) setViewport(pt intgeom.Point2) { - if c.useViewBounds { - if c.viewBounds.Min.X() <= pt.X() && c.viewBounds.Max.X() >= pt.X()+c.ScreenWidth { - c.viewPos[0] = pt.X() - } else if c.viewBounds.Min.X() > pt.X() { - c.viewPos[0] = c.viewBounds.Min.X() - } else if c.viewBounds.Max.X() < pt.X()+c.ScreenWidth { - c.viewPos[0] = c.viewBounds.Max.X() - c.ScreenWidth +func (w *Window) setViewport(pt intgeom.Point2) { + if w.useViewBounds { + if w.viewBounds.Min.X() <= pt.X() && w.viewBounds.Max.X() >= pt.X()+w.ScreenWidth { + w.viewPos[0] = pt.X() + } else if w.viewBounds.Min.X() > pt.X() { + w.viewPos[0] = w.viewBounds.Min.X() + } else if w.viewBounds.Max.X() < pt.X()+w.ScreenWidth { + w.viewPos[0] = w.viewBounds.Max.X() - w.ScreenWidth } - if c.viewBounds.Min.Y() <= pt.Y() && c.viewBounds.Max.Y() >= pt.Y()+c.ScreenHeight { - c.viewPos[1] = pt.Y() - } else if c.viewBounds.Min.Y() > pt.Y() { - c.viewPos[1] = c.viewBounds.Min.Y() - } else if c.viewBounds.Max.Y() < pt.Y()+c.ScreenHeight { - c.viewPos[1] = c.viewBounds.Max.Y() - c.ScreenHeight + if w.viewBounds.Min.Y() <= pt.Y() && w.viewBounds.Max.Y() >= pt.Y()+w.ScreenHeight { + w.viewPos[1] = pt.Y() + } else if w.viewBounds.Min.Y() > pt.Y() { + w.viewPos[1] = w.viewBounds.Min.Y() + } else if w.viewBounds.Max.Y() < pt.Y()+w.ScreenHeight { + w.viewPos[1] = w.viewBounds.Max.Y() - w.ScreenHeight } } else { - c.viewPos = pt + w.viewPos = pt } - c.logicHandler.Trigger(event.ViewportUpdate, c.viewPos) + w.logicHandler.Trigger(event.ViewportUpdate, w.viewPos) } // GetViewportBounds reports what bounds the viewport has been set to, if any. -func (c *Controller) GetViewportBounds() (rect intgeom.Rect2, ok bool) { - return c.viewBounds, c.useViewBounds +func (w *Window) GetViewportBounds() (rect intgeom.Rect2, ok bool) { + return w.viewBounds, w.useViewBounds } // RemoveViewportBounds removes restrictions on the viewport's movement. It will not // cause the viewport to update immediately. -func (c *Controller) RemoveViewportBounds() { - c.useViewBounds = false +func (w *Window) RemoveViewportBounds() { + w.useViewBounds = false } // SetViewportBounds sets the minimum and maximum position of the viewport, including // screen dimensions -func (c *Controller) SetViewportBounds(rect intgeom.Rect2) { - if rect.Max[0] < c.ScreenWidth { - rect.Max[0] = c.ScreenWidth +func (w *Window) SetViewportBounds(rect intgeom.Rect2) { + if rect.Max[0] < w.ScreenWidth { + rect.Max[0] = w.ScreenWidth } - if rect.Max[1] < c.ScreenHeight { - rect.Max[1] = c.ScreenHeight + if rect.Max[1] < w.ScreenHeight { + rect.Max[1] = w.ScreenHeight } - c.useViewBounds = true - c.viewBounds = rect + w.useViewBounds = true + w.viewBounds = rect - newViewX := c.viewPos.X() - newViewY := c.viewPos.Y() + newViewX := w.viewPos.X() + newViewY := w.viewPos.Y() if newViewX < rect.Min[0] { newViewX = rect.Min[0] } else if newViewX > rect.Max[0] { @@ -73,7 +73,7 @@ func (c *Controller) SetViewportBounds(rect intgeom.Rect2) { newViewY = rect.Max[1] } - if newViewX != c.viewPos.X() || newViewY != c.viewPos.Y() { - c.setViewport(intgeom.Point2{newViewX, newViewY}) + if newViewX != w.viewPos.X() || newViewY != w.viewPos.Y() { + w.setViewport(intgeom.Point2{newViewX, newViewY}) } } diff --git a/viewport_test.go b/viewport_test.go index 20884046..3bf26b4c 100644 --- a/viewport_test.go +++ b/viewport_test.go @@ -14,7 +14,7 @@ func sleep() { } func TestViewport(t *testing.T) { - c1 := NewController() + c1 := NewWindow() err := c1.SceneMap.AddScene("blank", scene.Scene{}) if err != nil { t.Fatalf("Scene Add failed: %v", err) @@ -25,43 +25,57 @@ func TestViewport(t *testing.T) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{0, 0}) } c1.SetScreen(5, 5) - sleep() if (c1.viewPos) != (intgeom.Point2{5, 5}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{5, 5}) } + _, ok := c1.GetViewportBounds() + if ok { + t.Fatalf("viewport bounds should not be set on scene start") + } + c1.SetViewportBounds(intgeom.NewRect2(0, 0, 4, 4)) - sleep() if (c1.viewPos) != (intgeom.Point2{5, 5}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{5, 5}) } c1.SetScreen(-1, -1) - sleep() if (c1.viewPos) != (intgeom.Point2{0, 0}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{0, 0}) } c1.SetScreen(6, 6) - sleep() if (c1.viewPos) != (intgeom.Point2{0, 0}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{0, 0}) } c1.SetViewportBounds(intgeom.NewRect2(0, 0, 1000, 1000)) c1.SetScreen(20, 20) - sleep() if (c1.viewPos) != (intgeom.Point2{20, 20}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{20, 20}) } + c1.ShiftScreen(-1, -1) + if (c1.viewPos) != (intgeom.Point2{19, 19}) { + t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{19, 19}) + } c1.SetViewportBounds(intgeom.NewRect2(21, 21, 2000, 2000)) - sleep() if (c1.viewPos) != (intgeom.Point2{21, 21}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{21, 21}) } c1.SetScreen(1000, 1000) - sleep() c1.SetViewportBounds(intgeom.NewRect2(0, 0, 900, 900)) - sleep() + bds, ok := c1.GetViewportBounds() + if !ok { + t.Fatalf("viewport bounds were not enabled") + } + if bds != intgeom.NewRect2(0, 0, 900, 900) { + t.Fatalf("viewport bounds were not set: expected %v got %v", intgeom.NewRect2(0, 0, 900, 900), bds) + } if (c1.viewPos) != (intgeom.Point2{900 - c1.Width(), 900 - c1.Height()}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{900 - c1.Width(), 900 - c1.Height()}) } + c1.RemoveViewportBounds() + _, ok = c1.GetViewportBounds() + if ok { + t.Fatalf("viewport bounds were enabled after clear") + } + c1.SetViewportBounds(intgeom.NewRect2(0, 0, 900, 900)) c1.skipSceneCh <- "" @@ -70,4 +84,9 @@ func TestViewport(t *testing.T) { if (c1.viewPos) != (intgeom.Point2{0, 0}) { t.Fatalf("expected %v got %v", c1.viewPos, intgeom.Point2{0, 0}) } + + _, ok = c1.GetViewportBounds() + if ok { + t.Fatalf("viewport bounds should not be set on scene start") + } } diff --git a/controller.go b/window.go similarity index 57% rename from controller.go rename to window.go index 50df741e..ef364992 100644 --- a/controller.go +++ b/window.go @@ -1,11 +1,16 @@ package oak import ( + "context" "image" + "io" + "sort" "sync/atomic" + "time" "github.com/oakmound/oak/v3/alg/intgeom" "github.com/oakmound/oak/v3/collision" + "github.com/oakmound/oak/v3/debugstream" "github.com/oakmound/oak/v3/event" "github.com/oakmound/oak/v3/key" "github.com/oakmound/oak/v3/mouse" @@ -13,21 +18,23 @@ import ( "github.com/oakmound/oak/v3/scene" "github.com/oakmound/oak/v3/shiny/driver" "github.com/oakmound/oak/v3/shiny/screen" - "github.com/oakmound/oak/v3/timing" + "github.com/oakmound/oak/v3/window" ) -func (c *Controller) windowController(s screen.Screen, x, y int32, width, height int) (screen.Window, error) { +var _ window.Window = &Window{} + +func (w *Window) windowController(s screen.Screen, x, y int32, width, height int) (screen.Window, error) { return s.NewWindow(screen.NewWindowGenerator( screen.Dimensions(width, height), - screen.Title(c.config.Title), + screen.Title(w.config.Title), screen.Position(x, y), - screen.Fullscreen(c.config.Fullscreen), - screen.Borderless(c.config.Borderless), - screen.TopMost(c.config.TopMost), + screen.Fullscreen(w.config.Fullscreen), + screen.Borderless(w.config.Borderless), + screen.TopMost(w.config.TopMost), )) } -type Controller struct { +type Window struct { key.State // TODO: most of these channels are not closed cleanly @@ -51,6 +58,8 @@ type Controller struct { // drawing should cease (or resume) drawCh chan struct{} + betweenDrawCh chan func() + // ScreenWidth is the width of the screen ScreenWidth int // ScreenHeight is the height of the screen @@ -79,7 +88,7 @@ type Controller struct { windowRect image.Rectangle // DrawTicker is the parallel to LogicTicker to set the draw framerate - DrawTicker *timing.DynamicTicker + DrawTicker *time.Ticker bkgFn func() image.Image @@ -99,7 +108,7 @@ type Controller struct { Driver Driver // prePublish is a function called each draw frame prior to - prePublish func(c *Controller, tx screen.Texture) + prePublish func(w *Window, tx screen.Texture) // LoadingR is a renderable that is displayed during loading screens. LoadingR render.Renderable @@ -137,6 +146,11 @@ type Controller struct { config Config + mostRecentInput InputType + + exitError error + ParentContext context.Context + TrackMouseClicks bool startupLoading bool useViewBounds bool @@ -151,19 +165,20 @@ var ( nextControllerID = new(int32) ) -func NewController() *Controller { - c := &Controller{ - State: key.NewState(), - transitionCh: make(chan struct{}), - sceneCh: make(chan struct{}), - skipSceneCh: make(chan string), - quitCh: make(chan struct{}), - drawCh: make(chan struct{}), +func NewWindow() *Window { + c := &Window{ + State: key.NewState(), + transitionCh: make(chan struct{}), + sceneCh: make(chan struct{}), + skipSceneCh: make(chan string), + quitCh: make(chan struct{}), + drawCh: make(chan struct{}), + betweenDrawCh: make(chan func()), } c.SceneMap = scene.NewMap() c.Driver = driver.Main - c.prePublish = func(*Controller, screen.Texture) {} + c.prePublish = func(*Window, screen.Texture) {} c.bkgFn = func() image.Image { return image.Black } @@ -176,43 +191,63 @@ func NewController() *Controller { c.TrackMouseClicks = true c.commands = make(map[string]func([]string)) c.ControllerID = atomic.AddInt32(nextControllerID, 1) + c.ParentContext = context.Background() return c } // Propagate triggers direct mouse events on entities which are clicked -func (c *Controller) Propagate(eventName string, me mouse.Event) { - c.LastMouseEvent = me +func (w *Window) Propagate(eventName string, me mouse.Event) { + w.LastMouseEvent = me mouse.LastEvent = me - hits := c.MouseTree.SearchIntersect(me.ToSpace().Bounds()) + hits := w.MouseTree.SearchIntersect(me.ToSpace().Bounds()) + sort.Slice(hits, func(i, j int) bool { + return hits[i].Location.Min.Z() < hits[i].Location.Max.Z() + }) for _, sp := range hits { - sp.CID.Trigger(eventName, me) + <-sp.CID.TriggerBus(eventName, &me, w.logicHandler) + if me.StopPropagation { + break + } } + me.StopPropagation = false - if c.TrackMouseClicks { + if w.TrackMouseClicks { if eventName == mouse.PressOn+"Relative" { - c.lastRelativePress = me + w.lastRelativePress = me } else if eventName == mouse.PressOn { - c.LastMousePress = me + w.LastMousePress = me } else if eventName == mouse.ReleaseOn { - if me.Button == c.LastMousePress.Button { - pressHits := c.MouseTree.SearchIntersect(c.LastMousePress.ToSpace().Bounds()) + if me.Button == w.LastMousePress.Button { + pressHits := w.MouseTree.SearchIntersect(w.LastMousePress.ToSpace().Bounds()) + sort.Slice(pressHits, func(i, j int) bool { + return pressHits[i].Location.Min.Z() < pressHits[i].Location.Max.Z() + }) for _, sp1 := range pressHits { for _, sp2 := range hits { if sp1.CID == sp2.CID { - event.Trigger(mouse.Click, me) - sp1.CID.Trigger(mouse.ClickOn, me) + w.logicHandler.Trigger(mouse.Click, &me) + <-sp1.CID.TriggerBus(mouse.ClickOn, &me, w.logicHandler) + if me.StopPropagation { + return + } } } } } } else if eventName == mouse.ReleaseOn+"Relative" { - if me.Button == c.lastRelativePress.Button { - pressHits := c.MouseTree.SearchIntersect(c.lastRelativePress.ToSpace().Bounds()) + if me.Button == w.lastRelativePress.Button { + pressHits := w.MouseTree.SearchIntersect(w.lastRelativePress.ToSpace().Bounds()) + sort.Slice(pressHits, func(i, j int) bool { + return pressHits[i].Location.Min.Z() < pressHits[i].Location.Max.Z() + }) for _, sp1 := range pressHits { for _, sp2 := range hits { if sp1.CID == sp2.CID { - sp1.CID.Trigger(mouse.ClickOn+"Relative", me) + sp1.CID.Trigger(mouse.ClickOn+"Relative", &me) + if me.StopPropagation { + return + } } } } @@ -221,56 +256,83 @@ func (c *Controller) Propagate(eventName string, me mouse.Event) { } } -func (c *Controller) Width() int { - return c.ScreenWidth +func (w *Window) Width() int { + return w.ScreenWidth } -func (c *Controller) Height() int { - return c.ScreenHeight +func (w *Window) Height() int { + return w.ScreenHeight } -func (c *Controller) Viewport() intgeom.Point2 { - return c.viewPos +func (w *Window) Viewport() intgeom.Point2 { + return w.viewPos } -func (c *Controller) ViewportBounds() intgeom.Rect2 { - return c.viewBounds +func (w *Window) ViewportBounds() intgeom.Rect2 { + return w.viewBounds } -func (c *Controller) SetLoadingRenderable(r render.Renderable) { - c.LoadingR = r +func (w *Window) SetLoadingRenderable(r render.Renderable) { + w.LoadingR = r } -func (c *Controller) SetBackground(b Background) { - c.bkgFn = func() image.Image { +func (w *Window) SetBackground(b Background) { + w.bkgFn = func() image.Image { return b.GetRGBA() } } -func (c *Controller) SetColorBackground(img image.Image) { - c.bkgFn = func() image.Image { +func (w *Window) SetColorBackground(img image.Image) { + w.bkgFn = func() image.Image { return img } } -func (c *Controller) GetBackgroundImage() image.Image { - return c.bkgFn() +func (w *Window) GetBackgroundImage() image.Image { + return w.bkgFn() } // SetLogicHandler swaps the logic system of the engine with some other // implementation. If this is never called, it will use event.DefaultBus -func (c *Controller) SetLogicHandler(h event.Handler) { - c.logicHandler = h +func (w *Window) SetLogicHandler(h event.Handler) { + w.logicHandler = h +} + +func (w *Window) NextScene() { + w.skipSceneCh <- "" +} + +func (w *Window) GoToScene(nextScene string) { + w.skipSceneCh <- nextScene +} + +func (w *Window) InFocus() bool { + return w.inFocus +} + +// CollisionTrees helps access the mouse and collision trees from the controller. +// These trees together detail how a controller can drive mouse and entity interactions. +func (w *Window) CollisionTrees() (mouseTree, collisionTree *collision.Tree) { + return w.MouseTree, w.CollisionTree +} + +func (w *Window) EventHandler() event.Handler { + return w.logicHandler } -func (c *Controller) NextScene() { - c.skipSceneCh <- "" +// MostRecentInput returns the most recent input type (e.g keyboard/mouse or joystick) +// recognized by the window. This value will only change if the controller's Config is +// set to TrackInputChanges +func (w *Window) MostRecentInput() InputType { + return w.mostRecentInput } -func (c *Controller) GoToScene(nextScene string) { - c.skipSceneCh <- nextScene +func (w *Window) exitWithError(err error) { + w.exitError = err + w.Quit() } -func (c *Controller) InFocus() bool { - return c.inFocus +func (w *Window) debugConsole(input io.Reader, output io.Writer) { + debugstream.AttachToStream(w.ParentContext, input, output) + debugstream.AddDefaultsForScope(w.ControllerID, w) } diff --git a/window/window.go b/window/window.go index 13ee1a79..5bb646f4 100644 --- a/window/window.go +++ b/window/window.go @@ -1,7 +1,12 @@ package window -import "github.com/oakmound/oak/v3/alg/intgeom" +import ( + "github.com/oakmound/oak/v3/alg/intgeom" + "github.com/oakmound/oak/v3/event" +) +// Window is an interface of methods on an oak.Controller used +// to avoid circular imports type Window interface { SetFullScreen(bool) error SetBorderless(bool) error @@ -11,14 +16,22 @@ type Window interface { ShowNotification(title, msg string, icon bool) error MoveWindow(x, y, w, h int) error HideCursor() error + //GetMonitorSize() (int, int) //Close() error + Width() int Height() int Viewport() intgeom.Point2 - Quit() SetViewportBounds(intgeom.Rect2) + NextScene() GoToScene(string) + InFocus() bool + ShiftScreen(int, int) + SetScreen(int, int) + Quit() + + EventHandler() event.Handler } diff --git a/controller_test.go b/window_test.go similarity index 87% rename from controller_test.go rename to window_test.go index 1765138a..967a99a5 100644 --- a/controller_test.go +++ b/window_test.go @@ -10,14 +10,15 @@ import ( ) func TestMouseClicks(t *testing.T) { - c1 := NewController() + c1 := NewWindow() sp := collision.NewFullSpace(0, 0, 100, 100, 1, 0) var triggered bool - go event.ResolvePending() + go event.ResolveChanges() event.GlobalBind(mouse.Click, func(event.CID, interface{}) int { triggered = true return 0 }) + time.Sleep(2 * time.Second) mouse.DefaultTree.Add(sp) c1.Propagate(mouse.PressOn, mouse.NewEvent(5, 5, mouse.ButtonLeft, mouse.PressOn)) c1.Propagate(mouse.ReleaseOn, mouse.NewEvent(5, 5, mouse.ButtonLeft, mouse.ReleaseOn)) @@ -28,26 +29,33 @@ func TestMouseClicks(t *testing.T) { } func TestMouseClicksRelative(t *testing.T) { - c1 := NewController() + c1 := NewWindow() sp := collision.NewFullSpace(0, 0, 100, 100, 1, 0) var triggered bool - go c1.logicHandler.(*event.Bus).ResolvePending() + go c1.logicHandler.(*event.Bus).ResolveChanges() c1.logicHandler.GlobalBind(mouse.ClickOn+"Relative", func(event.CID, interface{}) int { triggered = true return 0 }) + time.Sleep(2 * time.Second) c1.MouseTree.Add(sp) c1.Propagate(mouse.PressOn+"Relative", mouse.NewEvent(5, 5, mouse.ButtonLeft, mouse.PressOn)) c1.Propagate(mouse.ReleaseOn+"Relative", mouse.NewEvent(5, 5, mouse.ButtonLeft, mouse.ReleaseOn)) - time.Sleep(2 * time.Second) + time.Sleep(3 * time.Second) if !triggered { t.Fatalf("propagation failed to trigger click binding") } } +type ent struct{} + +func (e ent) Init() event.CID { + return 0 +} + func TestPropagate(t *testing.T) { - c1 := NewController() - go event.ResolvePending() + c1 := NewWindow() + go event.ResolveChanges() var triggered bool cid := event.CID(0).Parse(ent{}) s := collision.NewSpace(10, 10, 10, 10, cid)