From 80e0c9eb6a7c70c2fbd954093192bbb0ad1971b3 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 11 Dec 2025 16:47:18 +0200 Subject: [PATCH 01/59] Take parent node visibility into account --- game/asset/mdl/material.go | 2 +- game/binding.go | 12 ++++++------ game/hierarchy/scene.go | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/game/asset/mdl/material.go b/game/asset/mdl/material.go index 064fa01e..0533cefb 100644 --- a/game/asset/mdl/material.go +++ b/game/asset/mdl/material.go @@ -182,7 +182,7 @@ func NewMaterialPass() *MaterialPass { frontFace: FaceOrientationCCW, depthTest: true, depthWrite: true, - depthComparison: ComparisonLess, + depthComparison: ComparisonLessOrEqual, blending: false, } } diff --git a/game/binding.go b/game/binding.go index 4c848e56..10a5e93e 100644 --- a/game/binding.go +++ b/game/binding.go @@ -74,7 +74,7 @@ func NewSkyBinding() hierarchy.InterpolationBinding[*graphics.Sky] { type skyBinding struct{} func (b *skyBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, sky *graphics.Sky, fraction float64) { - active := scene.IsNodeVisible(id) + active := scene.IsNodeVisibleRecursive(id) sky.SetActive(active) } @@ -92,7 +92,7 @@ type ambientLightBinding struct{} func (b *ambientLightBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, light *graphics.AmbientLight, fraction float64) { matrix := scene.NodeInterpolatedAbsoluteMatrix(id, fraction) - visible := scene.IsNodeVisible(id) + visible := scene.IsNodeVisibleRecursive(id) light.SetPosition(matrix.Translation()) light.SetActive(visible) @@ -111,7 +111,7 @@ type pointLightBinding struct{} func (b *pointLightBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, light *graphics.PointLight, fraction float64) { matrix := scene.NodeInterpolatedAbsoluteMatrix(id, fraction) - visible := scene.IsNodeVisible(id) + visible := scene.IsNodeVisibleRecursive(id) light.SetPosition(matrix.Translation()) light.SetActive(visible) @@ -130,7 +130,7 @@ type spotLightBinding struct{} func (b *spotLightBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, light *graphics.SpotLight, fraction float64) { matrix := scene.NodeInterpolatedAbsoluteMatrix(id, fraction) - visible := scene.IsNodeVisible(id) + visible := scene.IsNodeVisibleRecursive(id) translation, rotation, _ := matrix.TRS() light.SetPosition(translation) @@ -151,7 +151,7 @@ type directionalLightBinding struct{} func (b *directionalLightBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, light *graphics.DirectionalLight, fraction float64) { matrix := scene.NodeInterpolatedAbsoluteMatrix(id, fraction) - visible := scene.IsNodeVisible(id) + visible := scene.IsNodeVisibleRecursive(id) translation, rotation, _ := matrix.TRS() light.SetPosition(translation) @@ -172,7 +172,7 @@ type meshBinding struct{} func (b *meshBinding) OnNodeToInterpolation(scene *hierarchy.Scene, id hierarchy.NodeID, mesh *graphics.Mesh, fraction float64) { matrix := scene.NodeInterpolatedAbsoluteMatrix(id, fraction) - visible := scene.IsNodeVisible(id) + visible := scene.IsNodeVisibleRecursive(id) mesh.SetMatrix(matrix) mesh.SetActive(visible) diff --git a/game/hierarchy/scene.go b/game/hierarchy/scene.go index c0d04e30..d685166f 100644 --- a/game/hierarchy/scene.go +++ b/game/hierarchy/scene.go @@ -120,6 +120,22 @@ func (s *Scene) IsNodeVisible(id NodeID) bool { return node.getIsVisible(s) } +// IsNodeVisibleRecursive returns whether the node with the specified ID is +// marked as visible, as well as all parent nodes. +func (s *Scene) IsNodeVisibleRecursive(id NodeID) bool { + node := s.fetchNode(id) + if !node.getIsVisible(s) { + return false + } + for node.parentIndex != -1 { + node = &s.nodes[node.parentIndex] + if !node.getIsVisible(s) { + return false + } + } + return true +} + // SetNodeVisible sets whether the node with the specified ID is marked as // visible. func (s *Scene) SetNodeVisible(id NodeID, visible bool) { From 692be6ad89a678ca9c47e4d79e8b2284f84542d6 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Mon, 15 Dec 2025 17:11:55 +0200 Subject: [PATCH 02/59] Add reverse animation node --- game/animation/node_reverse.go | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 game/animation/node_reverse.go diff --git a/game/animation/node_reverse.go b/game/animation/node_reverse.go new file mode 100644 index 00000000..ca30e34b --- /dev/null +++ b/game/animation/node_reverse.go @@ -0,0 +1,85 @@ +package animation + +// NewReverseNode creates a new animation node that plays the underlying +// animation in reverse. +func NewReverseNode(delegate Node) *ReverseNode { + return &ReverseNode{ + delegate: delegate, + } +} + +// ReverseNode is a decorator for an animation source that plays the +// underlying animation in reverse. +type ReverseNode struct { + delegate Node +} + +var _ Node = (*ReverseNode)(nil) + +// Rate returns the fraction of the animation length that advances each +// second. +func (n *ReverseNode) Rate() float64 { + return n.delegate.Rate() +} + +// Fraction returns the amount of animation that has elapsed. In case of +// looping, the value will wrap around. +// +// The returned value is in the range [0.0..1.0). +func (n *ReverseNode) Fraction() float64 { + return maxFraction - n.delegate.Fraction() +} + +// SetFraction relocates the animation to the specified fractional position. +// +// NOTE: This resets the animation and accumulated delta is lost. +func (n *ReverseNode) SetFraction(fraction float64) { + n.delegate.SetFraction(maxFraction - fraction) +} + +// Advance moves the animation forward by the specified delta seconds. +// +// The synchronizationRate determines the amount of scaling on the seconds +// that should be applied in order to be correctly synchronized with sibling +// and parent nodes in case of synchronization. +func (n *ReverseNode) Advance(seconds, synchronizationRate float64) { + n.delegate.Advance(-seconds, synchronizationRate) +} + +// IsSynchronized returns whether the node should be synchronized. +func (n *ReverseNode) IsSynchronized() bool { + return n.delegate.IsSynchronized() +} + +// SetSynchronized configures whether the node should be synchronized. +func (n *ReverseNode) SetSynchronized(synchronized bool) { + n.delegate.SetSynchronized(synchronized) +} + +// Synchronize is called each frame to allow a node to synchronized its +// children (depending on their setting). +// +// This will be called (and should be called on children) regardless if +// the current or any child node is synchronized or not. +func (n *ReverseNode) Synchronize() { + n.delegate.Synchronize() +} + +// BoneTransform returns the transformation of the specified bone. Keep in +// mind that this is after a fixed interval update has been applied. If +// this is called from within a dynamic update handler, the +// BoneTransformInterpolation method should be used instead. +func (n *ReverseNode) BoneTransform(bone string) NodeTransform { + return n.delegate.BoneTransform(bone) +} + +// BoneDeltaTransform returns the transformation that the bone will experience +// throughout the next delta interval. This is used for root motion. +func (n *ReverseNode) BoneDeltaTransform(bone string, delta float64) NodeTransform { + return n.delegate.BoneDeltaTransform(bone, -delta) +} + +// Reverse creates a new node that reverses the playback of the specified +func Reverse(node Node) *ReverseNode { + return NewReverseNode(node) +} From dd4e80d2b2d6adfcbd72be141b0eb483578259fb Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Mon, 15 Dec 2025 17:12:07 +0200 Subject: [PATCH 03/59] Improvements to graph animation node --- game/animation/node_graph.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/game/animation/node_graph.go b/game/animation/node_graph.go index 17832dbd..10dc520c 100644 --- a/game/animation/node_graph.go +++ b/game/animation/node_graph.go @@ -1,6 +1,8 @@ package animation import ( + "fmt" + "github.com/mokiat/gog/opt" "github.com/mokiat/gomath/dprec" ) @@ -53,27 +55,39 @@ func (n *GraphNode[T]) AddTransition(from, to T, transition GraphNodeTransition) n.transitions[pair] = transition } -// JumpToState jumps to a specific state and cancels any transitions. -func (n *GraphNode[T]) JumpToState(state T) { +// JumpTo jumps to a specific state and cancels any transitions. +func (n *GraphNode[T]) JumpTo(state T, rewind bool) { n.fromState = state n.toState = state n.transitionFraction = 1.0 n.SetFraction(0.0) + if rewind { + n.animations[state].SetFraction(0.0) + } } // TransitionTo triggers a new transition. Calling this while there is an // ongoing transition has undefined behavior. func (n *GraphNode[T]) TransitionTo(to T) { + if n.fromState == to { + return // already in the desired state + } + if n.toState == to { + return // already transitioning to state + } transition, ok := n.transitions[graphStatePair[T]{ from: n.fromState, to: to, }] if !ok { - panic("unknown transition") // TODO: Just jump to target state. + panic(fmt.Errorf("unknown transition %v -> %v", n.fromState, to)) // TODO: Just jump to target state. } if transitionAnimation, ok := transition.Animation.Unwrap(); ok { transitionAnimation.SetFraction(0.0) } + if transition.Rewind { + n.animations[to].SetFraction(0.0) + } n.toState = to n.transitionFraction = 0.0 } @@ -249,6 +263,11 @@ type graphStatePair[T comparable] struct { // GraphNodeTransition represents a transition in a GraphNode. type GraphNodeTransition struct { + + // Rewind indicates whether the target state's animation should be rewound + // to the beginning when the transition starts. + Rewind bool + // FadeInFraction determines the amount of time (in fraction of the total // animation) that it takes to fade into the transition animation (if there // is one). From 979c7bf4739b4d44fbe90b0fe868d7ed9b71a256 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Mon, 29 Dec 2025 16:52:07 +0200 Subject: [PATCH 04/59] Start work on new Audio API --- audio/api.go | 23 ++++++++++++++++++ audio/node.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ audio/nop.go | 24 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 audio/node.go diff --git a/audio/api.go b/audio/api.go index e5b2d935..a2bf47a3 100644 --- a/audio/api.go +++ b/audio/api.go @@ -7,5 +7,28 @@ type API interface { CreateMedia(info MediaInfo) Media // Play plays the specified media as soon as possible. + // + // TODO: REMOVE THIS!!!! Play(media Media, info PlayInfo) Playback + + // CreatePlayback creates a new playback node for the specified media. + CreatePlayback(media Media, loop bool) PlaybackNode + + // CreateOscillator creates a new oscillator node. + CreateOscillator() OscillatorNode + + // CreateGain creates a new gain node. + CreateGain() GainNode + + // CreatePan creates a new pan node. + CreatePan() PanNode + + // Connect connects the source node to the target node. + Connect(source, target Node) + + // Disconnect disconnects the source node from the target node. + Disconnect(source, target Node) + + // Output returns the output audio node. + Output() Node } diff --git a/audio/node.go b/audio/node.go new file mode 100644 index 00000000..2bf75cd8 --- /dev/null +++ b/audio/node.go @@ -0,0 +1,65 @@ +package audio + +// Node represents a node in a chain of audio elements. Each node produces +// audio data which can be synthesized, processed, or played back. +type Node any + +// UserNode represents an audio node that requires explicit resource management. +type UserNode interface { + Node + + // Delete releases any resources associated with the node. After calling + // this method, the node should not be used anymore. + Delete() +} + +// PlaybackNode represents an audio node that plays back audio data from a +// Media source. +type PlaybackNode interface { + UserNode + + // Loop returns true if the playback is set to loop when it reaches the end + // of the media. + Loop() bool + + // Done returns true if the playback has reached the end of the media + // and is not looping. + Done() bool +} + +// OscillatorNode represents an audio node that generates periodic waveforms. +type OscillatorNode interface { + UserNode + + // Frequency returns the frequency of the oscillator in Hertz. + Frequency() float32 + + // SetFrequency sets the frequency of the oscillator in Hertz. + SetFrequency(frequency float32) +} + +// GainNode represents an audio node that applies a gain (volume adjustment) to +// the audio signal. +type GainNode interface { + UserNode + + // Gain returns the gain factor applied to the audio signal. + Gain() float32 + + // SetGain sets the gain factor applied to the audio signal. + SetGain(gain float32) +} + +// PanNode represents an audio node that applies panning to the audio signal, +// distributing the signal between left and right channels. +type PanNode interface { + UserNode + + // Pan returns the pan value, where -1.0 is full left, 0.0 is center, and + // 1.0 is full right. + Pan() float32 + + // SetPan sets the pan value, where -1.0 is full left, 0.0 is center, and + // 1.0 is full right. + SetPan(pan float32) +} diff --git a/audio/nop.go b/audio/nop.go index e13890c3..690054f7 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -17,6 +17,30 @@ func (a *nopAPI) Play(media Media, info PlayInfo) Playback { return &nopPlayback{} } +func (a *nopAPI) CreatePlayback(media Media, loop bool) PlaybackNode { + return nil +} + +func (a *nopAPI) CreateOscillator() OscillatorNode { + return nil +} + +func (a *nopAPI) CreateGain() GainNode { + return nil +} + +func (a *nopAPI) CreatePan() PanNode { + return nil +} + +func (a *nopAPI) Connect(source, target Node) {} + +func (a *nopAPI) Disconnect(source, target Node) {} + +func (a *nopAPI) Output() Node { + return nil +} + type nopMedia struct{} func (m *nopMedia) Length() time.Duration { From 25cd4eb060a83bf20ec406e96caa069407044f67 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 8 Jan 2026 16:27:20 +0200 Subject: [PATCH 05/59] Add helper Chain method --- audio/api.go | 5 +++++ audio/nop.go | 2 ++ 2 files changed, 7 insertions(+) diff --git a/audio/api.go b/audio/api.go index a2bf47a3..f0bfc46a 100644 --- a/audio/api.go +++ b/audio/api.go @@ -23,6 +23,11 @@ type API interface { // CreatePan creates a new pan node. CreatePan() PanNode + // Chain connects the specified nodes in sequence. This is a convenience + // function that uses the Connect method of the API. Beware that it may + // incur allocations due to variadic parameters. + Chain(nodes ...Node) + // Connect connects the source node to the target node. Connect(source, target Node) diff --git a/audio/nop.go b/audio/nop.go index 690054f7..f15152b0 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -33,6 +33,8 @@ func (a *nopAPI) CreatePan() PanNode { return nil } +func (a *nopAPI) Chain(nodes ...Node) {} + func (a *nopAPI) Connect(source, target Node) {} func (a *nopAPI) Disconnect(source, target Node) {} From 4ba03edc58c614814b8b1f69f5fbe639c5c2f3be Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 8 Jan 2026 18:07:44 +0200 Subject: [PATCH 06/59] Add spatial panning --- audio/api.go | 20 +++++++++++++------- audio/listener.go | 19 +++++++++++++++++++ audio/node.go | 13 +++++++++++++ audio/nop.go | 36 +++++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 audio/listener.go diff --git a/audio/api.go b/audio/api.go index f0bfc46a..12fe5dcf 100644 --- a/audio/api.go +++ b/audio/api.go @@ -11,17 +11,20 @@ type API interface { // TODO: REMOVE THIS!!!! Play(media Media, info PlayInfo) Playback - // CreatePlayback creates a new playback node for the specified media. - CreatePlayback(media Media, loop bool) PlaybackNode + // CreatePlaybackNode creates a new playback node for the specified media. + CreatePlaybackNode(media Media, loop bool) PlaybackNode - // CreateOscillator creates a new oscillator node. - CreateOscillator() OscillatorNode + // CreateOscillatorNode creates a new oscillator node. + CreateOscillatorNode() OscillatorNode - // CreateGain creates a new gain node. - CreateGain() GainNode + // CreateGainNode creates a new gain node. + CreateGainNode() GainNode // CreatePan creates a new pan node. - CreatePan() PanNode + CreatePanNode() PanNode + + // CreateSpatialNode creates a new spatial audio node. + CreateSpatialNode() SpatialNode // Chain connects the specified nodes in sequence. This is a convenience // function that uses the Connect method of the API. Beware that it may @@ -34,6 +37,9 @@ type API interface { // Disconnect disconnects the source node from the target node. Disconnect(source, target Node) + // SpatialListener returns the spatial listener used for 3D audio. + SpatialListener() SpatialListener + // Output returns the output audio node. Output() Node } diff --git a/audio/listener.go b/audio/listener.go new file mode 100644 index 00000000..f8087323 --- /dev/null +++ b/audio/listener.go @@ -0,0 +1,19 @@ +package audio + +import "github.com/mokiat/gomath/sprec" + +// SpatialListener represents a listener in 3D space for spatial audio. +type SpatialListener interface { + + // Position returns the 3D position of the listener. + Position() sprec.Vec3 + + // SetPosition sets the 3D position of the listener. + SetPosition(position sprec.Vec3) + + // Rotation returns the orientation of the listener as a quaternion. + Rotation() sprec.Quat + + // SetRotation sets the orientation of the listener as a quaternion. + SetRotation(rotation sprec.Quat) +} diff --git a/audio/node.go b/audio/node.go index 2bf75cd8..0bd597ff 100644 --- a/audio/node.go +++ b/audio/node.go @@ -1,5 +1,7 @@ package audio +import "github.com/mokiat/gomath/sprec" + // Node represents a node in a chain of audio elements. Each node produces // audio data which can be synthesized, processed, or played back. type Node any @@ -63,3 +65,14 @@ type PanNode interface { // 1.0 is full right. SetPan(pan float32) } + +// SpatialNode represents an audio node that provides spatial audio effects. +type SpatialNode interface { + UserNode + + // Position returns the 3D position of the audio source. + Position() sprec.Vec3 + + // SetPosition sets the 3D position of the audio source. + SetPosition(position sprec.Vec3) +} diff --git a/audio/nop.go b/audio/nop.go index f15152b0..a4102577 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -1,6 +1,10 @@ package audio -import "time" +import ( + "time" + + "github.com/mokiat/gomath/sprec" +) // NewNopAPI returns an API that does nothing. func NewNopAPI() API { @@ -17,19 +21,23 @@ func (a *nopAPI) Play(media Media, info PlayInfo) Playback { return &nopPlayback{} } -func (a *nopAPI) CreatePlayback(media Media, loop bool) PlaybackNode { +func (a *nopAPI) CreatePlaybackNode(media Media, loop bool) PlaybackNode { + return nil +} + +func (a *nopAPI) CreateOscillatorNode() OscillatorNode { return nil } -func (a *nopAPI) CreateOscillator() OscillatorNode { +func (a *nopAPI) CreateGainNode() GainNode { return nil } -func (a *nopAPI) CreateGain() GainNode { +func (a *nopAPI) CreatePanNode() PanNode { return nil } -func (a *nopAPI) CreatePan() PanNode { +func (a *nopAPI) CreateSpatialNode() SpatialNode { return nil } @@ -39,6 +47,10 @@ func (a *nopAPI) Connect(source, target Node) {} func (a *nopAPI) Disconnect(source, target Node) {} +func (a *nopAPI) SpatialListener() SpatialListener { + return &nopListener{} +} + func (a *nopAPI) Output() Node { return nil } @@ -54,3 +66,17 @@ func (m *nopMedia) Delete() {} type nopPlayback struct{} func (p *nopPlayback) Stop() {} + +type nopListener struct{} + +func (l *nopListener) Position() sprec.Vec3 { + return sprec.ZeroVec3() +} + +func (l *nopListener) SetPosition(position sprec.Vec3) {} + +func (l *nopListener) Rotation() sprec.Quat { + return sprec.IdentityQuat() +} + +func (l *nopListener) SetRotation(rotation sprec.Quat) {} From 42d6037ce84073f3a188bd3e63976ba243a874d4 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 15 Jan 2026 17:40:58 +0200 Subject: [PATCH 07/59] Use a general resource package --- game/asset/dsl/algorithm.go | 70 ++++-- game/asset/dsl/chunk.go | 11 - game/asset/dsl/provider.go | 2 +- game/controller.go | 16 +- game/engine.go | 14 +- game/resource_registry.go | 13 +- resource/locator.go | 62 ++++++ resource/store.go | 25 +++ resource/store_file.go | 60 ++++++ resource/store_fs.go | 40 ++++ resource/store_mem.go | 52 +++++ resource/store_web.go | 55 +++++ resource/util.go | 7 + storage/chunked/asset.go | 15 +- storage/chunked/asset_test.go | 9 +- storage/chunked/storage.go | 168 --------------- ui/controller.go | 6 +- ui/resource_manager.go | 14 +- ui/resources.go | 40 ---- ui/resources/{ => fonts}/LICENSE.txt | 0 ui/resources/{ => fonts}/SOURCE.txt | 0 ui/resources/fonts/fs.go | 6 + ui/resources/{ => fonts}/roboto-bold.ttf | Bin ui/resources/{ => fonts}/roboto-italic.ttf | Bin ui/resources/{ => fonts}/roboto-mono-bold.ttf | Bin .../{ => fonts}/roboto-mono-italic.ttf | Bin .../{ => fonts}/roboto-mono-regular.ttf | Bin ui/resources/{ => fonts}/roboto-regular.ttf | Bin ui/resources/icons/LICENSE.txt | 202 ++++++++++++++++++ ui/resources/icons/SOURCE.txt | 1 + ui/resources/{ => icons}/checked.png | Bin ui/resources/{ => icons}/close.png | Bin ui/resources/{ => icons}/collapsed.png | Bin ui/resources/{ => icons}/expanded.png | Bin ui/resources/icons/fs.go | 6 + ui/resources/{ => icons}/unchecked.png | Bin util/resource/file.go | 40 ---- util/resource/fs.go | 26 --- util/resource/locator.go | 19 -- 39 files changed, 609 insertions(+), 370 deletions(-) delete mode 100644 game/asset/dsl/chunk.go create mode 100644 resource/locator.go create mode 100644 resource/store.go create mode 100644 resource/store_file.go create mode 100644 resource/store_fs.go create mode 100644 resource/store_mem.go create mode 100644 resource/store_web.go create mode 100644 resource/util.go delete mode 100644 storage/chunked/storage.go delete mode 100644 ui/resources.go rename ui/resources/{ => fonts}/LICENSE.txt (100%) rename ui/resources/{ => fonts}/SOURCE.txt (100%) create mode 100644 ui/resources/fonts/fs.go rename ui/resources/{ => fonts}/roboto-bold.ttf (100%) rename ui/resources/{ => fonts}/roboto-italic.ttf (100%) rename ui/resources/{ => fonts}/roboto-mono-bold.ttf (100%) rename ui/resources/{ => fonts}/roboto-mono-italic.ttf (100%) rename ui/resources/{ => fonts}/roboto-mono-regular.ttf (100%) rename ui/resources/{ => fonts}/roboto-regular.ttf (100%) create mode 100644 ui/resources/icons/LICENSE.txt create mode 100644 ui/resources/icons/SOURCE.txt rename ui/resources/{ => icons}/checked.png (100%) rename ui/resources/{ => icons}/close.png (100%) rename ui/resources/{ => icons}/collapsed.png (100%) rename ui/resources/{ => icons}/expanded.png (100%) create mode 100644 ui/resources/icons/fs.go rename ui/resources/{ => icons}/unchecked.png (100%) delete mode 100644 util/resource/file.go delete mode 100644 util/resource/fs.go delete mode 100644 util/resource/locator.go diff --git a/game/asset/dsl/algorithm.go b/game/asset/dsl/algorithm.go index a8461582..2745e7cf 100644 --- a/game/asset/dsl/algorithm.go +++ b/game/asset/dsl/algorithm.go @@ -3,19 +3,21 @@ package dsl import ( "errors" "fmt" + "io" "log/slog" "runtime" "time" "github.com/mokiat/gog/ds" "github.com/mokiat/gog/filter" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/storage/chunked" "golang.org/x/sync/errgroup" ) // Run runs the DSL algorithm on the provided registry. If modelNames // is not empty, only the models with the provided names will be processed. -func Run(storage chunked.Storage, pathFilter filter.Func[string]) error { +func Run(store resource.Store, pathFilter filter.Func[string]) error { var g errgroup.Group g.SetLimit(runtime.NumCPU()) @@ -24,7 +26,7 @@ func Run(storage chunked.Storage, pathFilter filter.Func[string]) error { continue // skip this one } g.Go(func() error { - if err := processAsset(storage, path, modelProvider); err != nil { + if err := processAsset(store, path, modelProvider); err != nil { return fmt.Errorf("error processing asset %q: %w", path, err) } return nil @@ -34,21 +36,20 @@ func Run(storage chunked.Storage, pathFilter filter.Func[string]) error { return g.Wait() } -func processAsset(storage chunked.Storage, path string, provider Provider[any]) error { +func processAsset(store resource.Store, path string, provider Provider[any]) error { startTime := time.Now() - digest, err := StringDigest(provider) + currentSourceDigest, err := StringDigest(provider) if err != nil { return fmt.Errorf("error calculating new digest: %w", err) } - asset := chunked.NewAsset(storage, path) - sourceDigest, err := retrieveSourceDigest(asset) + previousSourceDigest, err := openSourceDigest(store, path) if err != nil { return fmt.Errorf("error retrieving old digest: %w", err) } - if sourceDigest == digest { + if previousSourceDigest == currentSourceDigest { logger.Info("Asset skipped", slog.String("path", path), slog.String("duration", time.Since(startTime).String()), @@ -56,26 +57,28 @@ func processAsset(storage chunked.Storage, path string, provider Provider[any]) return nil } - chunkList := ds.NewList[chunked.Chunk](1) - chunkList.Add(chunked.FromValue(genChunkID, &genChunk{ - Digest: digest, - })) - resource, err := provider.Get() if err != nil { return fmt.Errorf("provider failed to produce asset: %w", err) } + + chunkList := ds.NewList[chunked.Chunk](1) for _, converter := range registeredConverters { if err := converter.Convert(chunkList, resource); err != nil { return fmt.Errorf("converter %T failed to convert resource: %w", converter, err) } } - chunks := chunked.ChunkList(chunkList.Unbox()) + + asset := chunked.NewAsset(store, path) if err := asset.Write(chunks); err != nil { return fmt.Errorf("error writing chunks: %w", err) } + if err := saveSourceDigest(store, path, currentSourceDigest); err != nil { + return fmt.Errorf("error saving source digest: %w", err) + } + logger.Info("Asset updated", slog.String("path", path), slog.Int("chunks", len(chunks)), @@ -84,16 +87,37 @@ func processAsset(storage chunked.Storage, path string, provider Provider[any]) return nil } -func retrieveSourceDigest(asset *chunked.Asset) (string, error) { - var holder genChunkHolder - if err := asset.Read(&holder); err != nil { - if errors.Is(err, chunked.ErrNotFound) { - return "", nil // no digest found - } - return "", fmt.Errorf("error reading asset: %w", err) - } - if holder.Gen == nil { +func openSourceDigest(store resource.Store, path string) (string, error) { + file, err := store.Open(digestPath(path)) + if errors.Is(err, resource.ErrNotFound) { return "", nil // no digest found } - return holder.Gen.Digest, nil + if err != nil { + return "", fmt.Errorf("error opening digest file: %w", err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("error reading digest file: %w", err) + } + return string(content), nil +} + +func saveSourceDigest(store resource.Store, path string, digest string) error { + file, err := store.Create(digestPath(path)) + if err != nil { + return fmt.Errorf("error creating digest file: %w", err) + } + defer file.Close() + + _, err = io.WriteString(file, digest) + if err != nil { + return fmt.Errorf("error writing digest file: %w", err) + } + return nil +} + +func digestPath(path string) string { + return fmt.Sprintf("%s.srcsha", path) } diff --git a/game/asset/dsl/chunk.go b/game/asset/dsl/chunk.go deleted file mode 100644 index 20097858..00000000 --- a/game/asset/dsl/chunk.go +++ /dev/null @@ -1,11 +0,0 @@ -package dsl - -var genChunkID = "lacking:gen" - -type genChunkHolder struct { - Gen *genChunk `chunk:"lacking:gen"` -} - -type genChunk struct { - Digest string -} diff --git a/game/asset/dsl/provider.go b/game/asset/dsl/provider.go index 84778ae2..62bd8130 100644 --- a/game/asset/dsl/provider.go +++ b/game/asset/dsl/provider.go @@ -35,7 +35,7 @@ func (p *funcProvider[T]) Digest() ([]byte, error) { // OnceProvider creates a provider that caches the result of the delegate // provider. func OnceProvider[T any](delegate Provider[T]) Provider[T] { - return FuncProvider[T]( + return FuncProvider( sync.OnceValues(func() (T, error) { return delegate.Get() }), diff --git a/game/controller.go b/game/controller.go index a7bb9bd9..b17530dc 100644 --- a/game/controller.go +++ b/game/controller.go @@ -6,7 +6,7 @@ import ( "github.com/mokiat/lacking/game/ecs" "github.com/mokiat/lacking/game/graphics" "github.com/mokiat/lacking/game/physics" - "github.com/mokiat/lacking/storage/chunked" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/util/async" ) @@ -15,9 +15,9 @@ import ( // to load and manage assets. The provided shader collection will be used // to render the game. The provided shader builder will be used to create // new shaders when needed. -func NewController(storage chunked.Storage, shaders graphics.ShaderCollection, shaderBuilder graphics.ShaderBuilder) *Controller { +func NewController(store resource.Store, shaders graphics.ShaderCollection, shaderBuilder graphics.ShaderBuilder) *Controller { return &Controller{ - storage: storage, + store: store, shaders: shaders, shaderBuilder: shaderBuilder, } @@ -31,7 +31,7 @@ var _ app.Controller = (*Controller)(nil) type Controller struct { app.NopController - storage chunked.Storage + store resource.Store shaders graphics.ShaderCollection shaderBuilder graphics.ShaderBuilder @@ -51,9 +51,9 @@ type Controller struct { viewport graphics.Viewport } -// Storage returns the storage to be used by the game. -func (c *Controller) Storage() chunked.Storage { - return c.storage +// Store returns the resource store to be used by the game. +func (c *Controller) Store() resource.Store { + return c.store } // UseGraphicsOptions allows to specify options that will be used @@ -97,7 +97,7 @@ func (c *Controller) OnCreate(window app.Window) { c.engine = NewEngine( WithGFXWorker(window), WithIOWorker(c.ioWorker), - WithStorage(c.storage), + WithStore(c.store), WithGraphics(c.gfxEngine), WithECS(c.ecsEngine), WithPhysics(c.physicsEngine), diff --git a/game/engine.go b/game/engine.go index 93530f0d..99e1eb2f 100644 --- a/game/engine.go +++ b/game/engine.go @@ -7,15 +7,15 @@ import ( "github.com/mokiat/lacking/game/graphics" "github.com/mokiat/lacking/game/physics" "github.com/mokiat/lacking/render" - "github.com/mokiat/lacking/storage/chunked" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/util/async" ) type EngineOption func(e *Engine) -func WithStorage(storage chunked.Storage) EngineOption { +func WithStore(registry resource.Store) EngineOption { return func(e *Engine) { - e.storage = storage + e.store = registry } } @@ -56,13 +56,13 @@ func NewEngine(opts ...EngineOption) *Engine { for _, opt := range opts { opt(result) } - result.registry = newResourceRegistry(result, result.storage) + result.registry = newResourceRegistry(result, result.store) result.registry.RegisterResourceLoader(newModelResourceLoader()) return result } type Engine struct { - storage chunked.Storage + store resource.Store ioWorker Worker gfxWorker Worker physicsEngine *physics.Engine @@ -85,8 +85,8 @@ func (e *Engine) Destroy() { // TODO: Release all scenes and all resource sets } -func (e *Engine) Storage() chunked.Storage { - return e.storage +func (e *Engine) Storage() resource.Store { + return e.store } func (e *Engine) IOWorker() Worker { diff --git a/game/resource_registry.go b/game/resource_registry.go index f63558aa..7e2a07bf 100644 --- a/game/resource_registry.go +++ b/game/resource_registry.go @@ -6,14 +6,15 @@ import ( "reflect" "sync" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/storage/chunked" "github.com/mokiat/lacking/util/async" ) -func newResourceRegistry(engine *Engine, storage chunked.Storage) *resourceRegistry { +func newResourceRegistry(engine *Engine, store resource.Store) *resourceRegistry { return &resourceRegistry{ - engine: engine, - storage: storage, + engine: engine, + store: store, resourceLoaders: make(map[reflect.Type]ResourceLoader[any]), resources: make(map[string]*resourceHandle), @@ -21,8 +22,8 @@ func newResourceRegistry(engine *Engine, storage chunked.Storage) *resourceRegis } type resourceRegistry struct { - engine *Engine - storage chunked.Storage + engine *Engine + store resource.Store mu sync.Mutex resourceLoaders map[reflect.Type]ResourceLoader[any] @@ -65,7 +66,7 @@ func (r *resourceRegistry) LoadResource(resourceSet *ResourceSet, path string, t promise: promise, refCount: 1, } - asset := chunked.NewAsset(r.storage, path) + asset := chunked.NewAsset(r.store, path) go func() { assetLoader := &AssetLoader{ engine: r.engine, diff --git a/resource/locator.go b/resource/locator.go new file mode 100644 index 00000000..c7c562a1 --- /dev/null +++ b/resource/locator.go @@ -0,0 +1,62 @@ +package resource + +import ( + "errors" + "io" + "strings" +) + +// ErrNotFound indicates that the specified content is not available. +var ErrNotFound = errors.New("not found") + +// Locator represents a logic by which resources can be opened for reading +// based off of a path. +type Locator interface { + + // Open opens the resource at the specified path for reading. + Open(path string) (io.ReadCloser, error) +} + +// LocatorFunc is a function type that implements the Locator interface. +type LocatorFunc func(path string) (io.ReadCloser, error) + +// Open opens the resource at the specified path for reading. +func (f LocatorFunc) Open(path string) (io.ReadCloser, error) { + return f(path) +} + +// OneOfLocator creates a Locator that tries each of the specified locators +// in order until one is able to successfully open the resource at the given +// path. +// +// If none of the locators are able to find the resource, an ErrNotFound +// error is returned. +// +// If any locator returns an error other than ErrNotFound, that error is +// returned immediately. +func OneOfLocator(locators ...Locator) Locator { + return LocatorFunc(func(path string) (io.ReadCloser, error) { + for _, locator := range locators { + in, err := locator.Open(path) + if errors.Is(err, ErrNotFound) { + continue // try next locator + } + return in, err + } + return nil, ErrNotFound + }) +} + +// SchemaLocator creates a Locator that delegates to the specified locator +// only if the path begins with the specified schema followed by ":///". +// +// If the path does not match the schema, an ErrNotFound error is returned. +func SchemaLocator(schema string, locator Locator) Locator { + prefix := schema + ":///" + return LocatorFunc(func(path string) (io.ReadCloser, error) { + if !strings.HasPrefix(path, prefix) { + return nil, ErrNotFound + } + return locator.Open(strings.TrimPrefix(path, prefix)) + }) +} diff --git a/resource/store.go b/resource/store.go new file mode 100644 index 00000000..2f07b7ae --- /dev/null +++ b/resource/store.go @@ -0,0 +1,25 @@ +package resource + +import "io" + +// Store represents a resource store that can manage multiple resources. +type Store interface { + + // Create opens a writer for the data of the specified asset. + // + // If the operation is not supported, an errors.ErrUnsupported is returned. + Create(path string) (io.WriteCloser, error) + + // Open opens a reader for the data of the specified asset. + Open(path string) (io.ReadCloser, error) + + // List returns all available assets. + // + // If the operation is not supported, an errors.ErrUnsupported is returned. + List() ([]string, error) + + // Delete removes the specified asset. + // + // If the operation is not supported, an errors.ErrUnsupported is returned. + Delete(path string) error +} diff --git a/resource/store_file.go b/resource/store_file.go new file mode 100644 index 00000000..2578068d --- /dev/null +++ b/resource/store_file.go @@ -0,0 +1,60 @@ +package resource + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" +) + +// NewFileStore creates a new Store that uses the file system. +func NewFileStore(baseDir string) (Store, error) { + root, err := os.OpenRoot(baseDir) + if err != nil { + return nil, fmt.Errorf("error opening base dir: %w", err) + } + return &fileStore{ + root: root, + }, nil +} + +type fileStore struct { + root *os.Root +} + +var _ Store = (*fileStore)(nil) + +func (s *fileStore) Create(path string) (io.WriteCloser, error) { + return s.root.Create(cleanFilePath(path)) +} + +func (s *fileStore) Open(path string) (io.ReadCloser, error) { + file, err := s.root.Open(cleanFilePath(path)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return file, err +} + +func (s *fileStore) List() ([]string, error) { + var result []string + err := fs.WalkDir(s.root.FS(), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() { + result = append(result, path) + } + return nil + }) + return result, err +} + +func (s *fileStore) Delete(path string) error { + err := s.root.Remove(cleanFilePath(path)) + if errors.Is(err, os.ErrNotExist) { + return ErrNotFound + } + return err +} diff --git a/resource/store_fs.go b/resource/store_fs.go new file mode 100644 index 00000000..6c722363 --- /dev/null +++ b/resource/store_fs.go @@ -0,0 +1,40 @@ +package resource + +import ( + "errors" + "io" + "io/fs" +) + +// NewFSStore creates a new Store that uses HTTP requests. +func NewFSStore(fileSystem fs.FS) Store { + return &fsStore{ + fileSystem: fileSystem, + } +} + +type fsStore struct { + fileSystem fs.FS +} + +var _ Store = (*fsStore)(nil) + +func (s *fsStore) Create(path string) (io.WriteCloser, error) { + return nil, errors.ErrUnsupported +} + +func (s *fsStore) Open(path string) (io.ReadCloser, error) { + in, err := s.fileSystem.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotFound + } + return in, err +} + +func (s *fsStore) List() ([]string, error) { + return nil, errors.ErrUnsupported +} + +func (s *fsStore) Delete(path string) error { + return errors.ErrUnsupported +} diff --git a/resource/store_mem.go b/resource/store_mem.go new file mode 100644 index 00000000..7afe7845 --- /dev/null +++ b/resource/store_mem.go @@ -0,0 +1,52 @@ +package resource + +import ( + "bytes" + "io" + "maps" + "slices" + + "github.com/mokiat/lacking/util/ioutil" +) + +// NewMemStore creates a new Store that uses memory. +func NewMemStore() Store { + return &memStore{ + objects: make(map[string]*bytes.Buffer), + } +} + +type memStore struct { + objects map[string]*bytes.Buffer +} + +var _ Store = (*memStore)(nil) + +func (s *memStore) Create(path string) (io.WriteCloser, error) { + path = cleanFilePath(path) + buffer := new(bytes.Buffer) + s.objects[path] = buffer + return ioutil.NopWriteCloser(buffer), nil +} + +func (s *memStore) Open(path string) (io.ReadCloser, error) { + path = cleanFilePath(path) + buffer, ok := s.objects[path] + if !ok { + return nil, ErrNotFound + } + return io.NopCloser(buffer), nil +} + +func (s *memStore) List() ([]string, error) { + return slices.Collect(maps.Keys(s.objects)), nil +} + +func (s *memStore) Delete(path string) error { + path = cleanFilePath(path) + if _, ok := s.objects[path]; !ok { + return ErrNotFound + } + delete(s.objects, path) + return nil +} diff --git a/resource/store_web.go b/resource/store_web.go new file mode 100644 index 00000000..8cd61099 --- /dev/null +++ b/resource/store_web.go @@ -0,0 +1,55 @@ +package resource + +import ( + "errors" + "fmt" + "io" + "net/http" + urlpath "path" +) + +// NewWebStore creates a new Store that uses HTTP requests. +func NewWebStore(baseURL string) (Store, error) { + return &webStore{ + baseURL: baseURL, + }, nil +} + +type webStore struct { + baseURL string +} + +var _ Store = (*webStore)(nil) + +func (s *webStore) Create(path string) (io.WriteCloser, error) { + return nil, errors.ErrUnsupported +} + +func (s *webStore) Open(path string) (io.ReadCloser, error) { + return s.fetch(urlpath.Join(s.baseURL, path)) +} + +func (s *webStore) List() ([]string, error) { + return nil, errors.ErrUnsupported +} + +func (s *webStore) Delete(path string) error { + return errors.ErrUnsupported +} + +func (s *webStore) fetch(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error performing request: %w", err) + } + if resp.StatusCode == http.StatusOK { + return resp.Body, nil + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusNotFound: + return nil, ErrNotFound + default: + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } +} diff --git a/resource/util.go b/resource/util.go new file mode 100644 index 00000000..05bcb4f1 --- /dev/null +++ b/resource/util.go @@ -0,0 +1,7 @@ +package resource + +import "path/filepath" + +func cleanFilePath(path string) string { + return filepath.Clean(filepath.FromSlash(path)) +} diff --git a/storage/chunked/asset.go b/storage/chunked/asset.go index d045ca20..f67fa46b 100644 --- a/storage/chunked/asset.go +++ b/storage/chunked/asset.go @@ -4,19 +4,20 @@ import ( "fmt" "github.com/mokiat/gblob" + "github.com/mokiat/lacking/resource" ) -func NewAsset(storage Storage, path string) *Asset { +func NewAsset(store resource.Store, path string) *Asset { path = cleanFilePath(path) return &Asset{ - storage: storage, - path: path, + store: store, + path: path, } } type Asset struct { - storage Storage - path string + store resource.Store + path string } func (a *Asset) Path() string { @@ -24,7 +25,7 @@ func (a *Asset) Path() string { } func (a *Asset) Read(target any) error { - in, err := a.storage.Open(a.path) + in, err := a.store.Open(a.path) if err != nil { return fmt.Errorf("error opening asset file: %w", err) } @@ -41,7 +42,7 @@ func (a *Asset) Read(target any) error { } func (a *Asset) Write(source any) error { - out, err := a.storage.Create(a.path) + out, err := a.store.Create(a.path) if err != nil { return fmt.Errorf("error creating asset file: %w", err) } diff --git a/storage/chunked/asset_test.go b/storage/chunked/asset_test.go index 6286f7b4..80f300eb 100644 --- a/storage/chunked/asset_test.go +++ b/storage/chunked/asset_test.go @@ -4,6 +4,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/storage/chunked" ) @@ -23,13 +24,13 @@ var _ = Describe("Asset", func() { } var ( - storage chunked.Storage - asset *chunked.Asset + store resource.Store + asset *chunked.Asset ) BeforeEach(func() { - storage = chunked.NewMemoryStorage() - asset = chunked.NewAsset(storage, "example.dat") + store = resource.NewMemStore() + asset = chunked.NewAsset(store, "example.dat") }) It("is possible to encode a struct with nil chunks", func() { diff --git a/storage/chunked/storage.go b/storage/chunked/storage.go deleted file mode 100644 index b54726d1..00000000 --- a/storage/chunked/storage.go +++ /dev/null @@ -1,168 +0,0 @@ -package chunked - -import ( - "bytes" - "errors" - "fmt" - "io" - "io/fs" - "maps" - "net/http" - "os" - urlpath "path" - "slices" - - "github.com/mokiat/lacking/util/ioutil" -) - -// ErrNotFound indicates that the specified content is not available. -var ErrNotFound = errors.New("not found") - -// Storage represents a storage interface for assets. -type Storage interface { - - // List returns all available assets. - List() ([]string, error) - - // Open opens a reader for the data of the specified asset. - Open(path string) (io.ReadCloser, error) - - // Create opens a writer for the data of the specified asset. - Create(path string) (io.WriteCloser, error) - - // Delete removes the specified asset. - Delete(path string) error -} - -// NewMemoryStorage creates a new storage that uses memory. -func NewMemoryStorage() Storage { - return &memStorage{ - objects: make(map[string]*bytes.Buffer), - } -} - -type memStorage struct { - objects map[string]*bytes.Buffer -} - -func (s *memStorage) List() ([]string, error) { - return slices.Collect(maps.Keys(s.objects)), nil -} - -func (s *memStorage) Open(path string) (io.ReadCloser, error) { - path = cleanFilePath(path) - buffer, ok := s.objects[path] - if !ok { - return nil, ErrNotFound - } - return io.NopCloser(buffer), nil -} - -func (s *memStorage) Create(path string) (io.WriteCloser, error) { - path = cleanFilePath(path) - buffer := new(bytes.Buffer) - s.objects[path] = buffer - return ioutil.NopWriteCloser(buffer), nil -} - -func (s *memStorage) Delete(path string) error { - path = cleanFilePath(path) - if _, ok := s.objects[path]; !ok { - return ErrNotFound - } - delete(s.objects, path) - return nil -} - -// NewFileStorage creates a new storage that uses the file system. -func NewFileStorage(baseDir string) (Storage, error) { - root, err := os.OpenRoot(baseDir) - if err != nil { - return nil, fmt.Errorf("error opening base dir: %w", err) - } - return &fileStorage{ - root: root, - }, nil -} - -type fileStorage struct { - root *os.Root -} - -func (s *fileStorage) List() ([]string, error) { - var result []string - err := fs.WalkDir(s.root.FS(), ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if !d.IsDir() { - result = append(result, path) - } - return nil - }) - return result, err -} - -func (s *fileStorage) Open(path string) (io.ReadCloser, error) { - file, err := s.root.Open(cleanFilePath(path)) - if errors.Is(err, os.ErrNotExist) { - return nil, ErrNotFound - } - return file, err -} - -func (s *fileStorage) Create(path string) (io.WriteCloser, error) { - return s.root.Create(cleanFilePath(path)) -} - -func (s *fileStorage) Delete(path string) error { - err := s.root.Remove(cleanFilePath(path)) - if errors.Is(err, os.ErrNotExist) { - return ErrNotFound - } - return err -} - -// NewWebStorage creates a new storage that uses HTTP requests. -func NewWebStorage(baseURL string) (Storage, error) { - return &webStorage{ - baseURL: baseURL, - }, nil -} - -type webStorage struct { - baseURL string -} - -func (s *webStorage) List() ([]string, error) { - return nil, errors.ErrUnsupported -} - -func (s *webStorage) Open(path string) (io.ReadCloser, error) { - return s.fetch(urlpath.Join(s.baseURL, path)) -} - -func (s *webStorage) Create(path string) (io.WriteCloser, error) { - return nil, errors.ErrUnsupported -} - -func (s *webStorage) Delete(path string) error { - return errors.ErrUnsupported -} - -func (s *webStorage) fetch(url string) (io.ReadCloser, error) { - resp, err := http.Get(url) - if err != nil { - return nil, fmt.Errorf("error performing request: %w", err) - } - if resp.StatusCode == http.StatusOK { - return resp.Body, nil - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusNotFound: - return nil, ErrNotFound - default: - return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) - } -} diff --git a/ui/controller.go b/ui/controller.go index f2a72423..22a62d29 100644 --- a/ui/controller.go +++ b/ui/controller.go @@ -3,7 +3,7 @@ package ui import ( "github.com/mokiat/lacking/app" "github.com/mokiat/lacking/debug/metric" - "github.com/mokiat/lacking/util/resource" + "github.com/mokiat/lacking/resource" ) // InitFunc can be used to initialize the Window with the @@ -12,7 +12,7 @@ type InitFunc func(window *Window) // NewController creates a new app.Controller that integrates // with the ui package to render a user interface. -func NewController(locator resource.ReadLocator, shaders ShaderCollection, initFn InitFunc) *Controller { +func NewController(locator resource.Locator, shaders ShaderCollection, initFn InitFunc) *Controller { return &Controller{ locator: locator, shaders: shaders, @@ -25,7 +25,7 @@ var _ app.Controller = (*Controller)(nil) type Controller struct { app.NopController - locator resource.ReadLocator + locator resource.Locator shaders ShaderCollection canvas *Canvas diff --git a/ui/resource_manager.go b/ui/resource_manager.go index 062289af..00dcb5e3 100644 --- a/ui/resource_manager.go +++ b/ui/resource_manager.go @@ -9,11 +9,11 @@ import ( _ "image/png" "github.com/mokiat/lacking/audio" - "github.com/mokiat/lacking/util/resource" + "github.com/mokiat/lacking/resource" "golang.org/x/image/font/opentype" ) -func newResourceManager(locator resource.ReadLocator, audioAPI audio.API, imgFact *imageFactory, fntFact *fontFactory) *resourceManager { +func newResourceManager(locator resource.Locator, audioAPI audio.API, imgFact *imageFactory, fntFact *fontFactory) *resourceManager { return &resourceManager{ locator: locator, imgFact: imgFact, @@ -23,7 +23,7 @@ func newResourceManager(locator resource.ReadLocator, audioAPI audio.API, imgFac } type resourceManager struct { - locator resource.ReadLocator + locator resource.Locator imgFact *imageFactory fntFact *fontFactory audioAPI audio.API @@ -34,7 +34,7 @@ func (m *resourceManager) CreateImage(img image.Image) *Image { } func (m *resourceManager) OpenImage(uri string) (*Image, error) { - in, err := m.locator.ReadResource(uri) + in, err := m.locator.Open(uri) if err != nil { return nil, fmt.Errorf("error opening resource: %w", err) } @@ -52,7 +52,7 @@ func (m *resourceManager) CreateFont(otFont *opentype.Font) (*Font, error) { } func (m *resourceManager) OpenFont(uri string) (*Font, error) { - in, err := m.locator.ReadResource(uri) + in, err := m.locator.Open(uri) if err != nil { return nil, fmt.Errorf("error opening resource: %w", err) } @@ -87,7 +87,7 @@ func (m *resourceManager) CreateFontCollection(collection *opentype.Collection) } func (m *resourceManager) OpenFontCollection(uri string) (*FontCollection, error) { - in, err := m.locator.ReadResource(uri) + in, err := m.locator.Open(uri) if err != nil { return nil, fmt.Errorf("error opening resource: %w", err) } @@ -106,7 +106,7 @@ func (m *resourceManager) OpenFontCollection(uri string) (*FontCollection, error } func (m *resourceManager) OpenSound(uri string) (*Sound, error) { - in, err := m.locator.ReadResource(uri) + in, err := m.locator.Open(uri) if err != nil { return nil, fmt.Errorf("error opening resource: %w", err) } diff --git a/ui/resources.go b/ui/resources.go deleted file mode 100644 index c860ae75..00000000 --- a/ui/resources.go +++ /dev/null @@ -1,40 +0,0 @@ -package ui - -import ( - "embed" - "io" - "io/fs" - "strings" - - "github.com/mokiat/lacking/util/resource" -) - -//go:embed resources/* -var uiResources embed.FS - -// WrapResourceLocator returns a new resource.ReadLocator that is capable of -// providing ui built-in resources as well as custom user resources. -func WrappedLocator(delegate resource.ReadLocator) resource.ReadLocator { - return &wrappedLocator{ - delegate: delegate, - } -} - -type wrappedLocator struct { - delegate resource.ReadLocator -} - -func (l *wrappedLocator) ReadResource(uri string) (io.ReadCloser, error) { - const matScheme = "ui:///" - if strings.HasPrefix(uri, matScheme) { - dir, err := fs.Sub(uiResources, "resources") - if err != nil { - return nil, err - } - return dir.Open(strings.TrimPrefix(uri, matScheme)) - } - if l.delegate == nil { - return nil, fs.ErrNotExist - } - return l.delegate.ReadResource(uri) -} diff --git a/ui/resources/LICENSE.txt b/ui/resources/fonts/LICENSE.txt similarity index 100% rename from ui/resources/LICENSE.txt rename to ui/resources/fonts/LICENSE.txt diff --git a/ui/resources/SOURCE.txt b/ui/resources/fonts/SOURCE.txt similarity index 100% rename from ui/resources/SOURCE.txt rename to ui/resources/fonts/SOURCE.txt diff --git a/ui/resources/fonts/fs.go b/ui/resources/fonts/fs.go new file mode 100644 index 00000000..711bce03 --- /dev/null +++ b/ui/resources/fonts/fs.go @@ -0,0 +1,6 @@ +package fonts + +import "embed" + +//go:embed *.ttf +var FS embed.FS diff --git a/ui/resources/roboto-bold.ttf b/ui/resources/fonts/roboto-bold.ttf similarity index 100% rename from ui/resources/roboto-bold.ttf rename to ui/resources/fonts/roboto-bold.ttf diff --git a/ui/resources/roboto-italic.ttf b/ui/resources/fonts/roboto-italic.ttf similarity index 100% rename from ui/resources/roboto-italic.ttf rename to ui/resources/fonts/roboto-italic.ttf diff --git a/ui/resources/roboto-mono-bold.ttf b/ui/resources/fonts/roboto-mono-bold.ttf similarity index 100% rename from ui/resources/roboto-mono-bold.ttf rename to ui/resources/fonts/roboto-mono-bold.ttf diff --git a/ui/resources/roboto-mono-italic.ttf b/ui/resources/fonts/roboto-mono-italic.ttf similarity index 100% rename from ui/resources/roboto-mono-italic.ttf rename to ui/resources/fonts/roboto-mono-italic.ttf diff --git a/ui/resources/roboto-mono-regular.ttf b/ui/resources/fonts/roboto-mono-regular.ttf similarity index 100% rename from ui/resources/roboto-mono-regular.ttf rename to ui/resources/fonts/roboto-mono-regular.ttf diff --git a/ui/resources/roboto-regular.ttf b/ui/resources/fonts/roboto-regular.ttf similarity index 100% rename from ui/resources/roboto-regular.ttf rename to ui/resources/fonts/roboto-regular.ttf diff --git a/ui/resources/icons/LICENSE.txt b/ui/resources/icons/LICENSE.txt new file mode 100644 index 00000000..75b52484 --- /dev/null +++ b/ui/resources/icons/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ui/resources/icons/SOURCE.txt b/ui/resources/icons/SOURCE.txt new file mode 100644 index 00000000..a027a21d --- /dev/null +++ b/ui/resources/icons/SOURCE.txt @@ -0,0 +1 @@ +https://fonts.google.com/ diff --git a/ui/resources/checked.png b/ui/resources/icons/checked.png similarity index 100% rename from ui/resources/checked.png rename to ui/resources/icons/checked.png diff --git a/ui/resources/close.png b/ui/resources/icons/close.png similarity index 100% rename from ui/resources/close.png rename to ui/resources/icons/close.png diff --git a/ui/resources/collapsed.png b/ui/resources/icons/collapsed.png similarity index 100% rename from ui/resources/collapsed.png rename to ui/resources/icons/collapsed.png diff --git a/ui/resources/expanded.png b/ui/resources/icons/expanded.png similarity index 100% rename from ui/resources/expanded.png rename to ui/resources/icons/expanded.png diff --git a/ui/resources/icons/fs.go b/ui/resources/icons/fs.go new file mode 100644 index 00000000..6d2b8eb1 --- /dev/null +++ b/ui/resources/icons/fs.go @@ -0,0 +1,6 @@ +package icons + +import "embed" + +//go:embed *.png +var FS embed.FS diff --git a/ui/resources/unchecked.png b/ui/resources/icons/unchecked.png similarity index 100% rename from ui/resources/unchecked.png rename to ui/resources/icons/unchecked.png diff --git a/util/resource/file.go b/util/resource/file.go deleted file mode 100644 index b823b780..00000000 --- a/util/resource/file.go +++ /dev/null @@ -1,40 +0,0 @@ -package resource - -import ( - "io" - "os" - "path/filepath" -) - -// NewFileLocator returns a new *FileLocator that is configured to access -// resources located on the local filesystem. -func NewFileLocator(dir string) *FileLocator { - return &FileLocator{ - dir: dir, - } -} - -var _ ReadLocator = (*FileLocator)(nil) -var _ WriteLocator = (*FileLocator)(nil) - -// FileLocator is an implementation of ReadLocator and WriteLocator that uses -// the local filesystem to access resources. -type FileLocator struct { - dir string -} - -func (l *FileLocator) ReadResource(path string) (io.ReadCloser, error) { - path = filepath.FromSlash(path) - if !filepath.IsAbs(path) { - path = filepath.Join(l.dir, path) - } - return os.Open(path) -} - -func (l *FileLocator) WriteResource(path string) (io.WriteCloser, error) { - path = filepath.FromSlash(path) - if !filepath.IsAbs(path) { - path = filepath.Join(l.dir, path) - } - return os.Create(path) -} diff --git a/util/resource/fs.go b/util/resource/fs.go deleted file mode 100644 index 0ca28d84..00000000 --- a/util/resource/fs.go +++ /dev/null @@ -1,26 +0,0 @@ -package resource - -import ( - "io" - "io/fs" -) - -// NewFSLocator returns a new instance of *FSLocator that uses the specified -// fs.FS to access resources. -func NewFSLocator(filesys fs.FS) *FSLocator { - return &FSLocator{ - filesys: filesys, - } -} - -var _ ReadLocator = (*FSLocator)(nil) - -// FSLocator is an implementation of ReadLocator that uses the fs.FS abstraction -// to load resources, allowing the API to be used with embedded files. -type FSLocator struct { - filesys fs.FS -} - -func (l *FSLocator) ReadResource(path string) (io.ReadCloser, error) { - return l.filesys.Open(path) -} diff --git a/util/resource/locator.go b/util/resource/locator.go deleted file mode 100644 index cf5c48cd..00000000 --- a/util/resource/locator.go +++ /dev/null @@ -1,19 +0,0 @@ -package resource - -import "io" - -// ReadLocator represents a logic by which resources can be opened for reading -// based off of a path. -type ReadLocator interface { - - // ReadResource opens the resource at the specified path for reading. - ReadResource(path string) (io.ReadCloser, error) -} - -// WriteLocator represents a logic by which resources can be opened for writing -// based off of a path. -type WriteLocator interface { - - // WriteResource opens the resource at the specified path for writing. - WriteResource(path string) (io.WriteCloser, error) -} From 84611ea3f1e60139de3cb9d097c3ed044c644ebb Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 17 Jan 2026 16:36:02 +0200 Subject: [PATCH 08/59] Allow saving of raw asset files --- game/asset/dsl/algorithm.go | 62 +++++++++++++++++++++++++++++++++++++ game/asset/dsl/file.go | 30 ++++++++++++++++++ game/asset/dsl/resource.go | 33 ++++++++++++++++++-- 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 game/asset/dsl/file.go diff --git a/game/asset/dsl/algorithm.go b/game/asset/dsl/algorithm.go index 2745e7cf..959ed316 100644 --- a/game/asset/dsl/algorithm.go +++ b/game/asset/dsl/algorithm.go @@ -21,6 +21,18 @@ func Run(store resource.Store, pathFilter filter.Func[string]) error { var g errgroup.Group g.SetLimit(runtime.NumCPU()) + for path, rawProvider := range rawResourceProviders { + if !pathFilter(path) { + continue // skip this one + } + g.Go(func() error { + if err := processRawAsset(store, path, rawProvider); err != nil { + return fmt.Errorf("error processing asset %q: %w", path, err) + } + return nil + }) + } + for path, modelProvider := range resourceProviders { if !pathFilter(path) { continue // skip this one @@ -87,6 +99,56 @@ func processAsset(store resource.Store, path string, provider Provider[any]) err return nil } +func processRawAsset(store resource.Store, path string, provider Provider[io.ReadCloser]) error { + startTime := time.Now() + + currentSourceDigest, err := StringDigest(provider) + if err != nil { + return fmt.Errorf("error calculating new digest: %w", err) + } + + previousSourceDigest, err := openSourceDigest(store, path) + if err != nil { + return fmt.Errorf("error retrieving old digest: %w", err) + } + + if previousSourceDigest == currentSourceDigest { + logger.Info("Asset skipped", + slog.String("path", path), + slog.String("duration", time.Since(startTime).String()), + ) + return nil + } + + in, err := provider.Get() + if err != nil { + return fmt.Errorf("provider failed to produce asset: %w", err) + } + defer in.Close() + + out, err := store.Create(path) + if err != nil { + return fmt.Errorf("error creating asset file: %w", err) + } + defer out.Close() + + size, err := io.Copy(out, in) + if err != nil { + return fmt.Errorf("error copying raw asset data: %w", err) + } + + if err := saveSourceDigest(store, path, currentSourceDigest); err != nil { + return fmt.Errorf("error saving source digest: %w", err) + } + + logger.Info("Asset updated", + slog.String("path", path), + slog.Int("size", int(size)), + slog.String("duration", time.Since(startTime).String()), + ) + return nil +} + func openSourceDigest(store resource.Store, path string) (string, error) { file, err := store.Open(digestPath(path)) if errors.Is(err, resource.ErrNotFound) { diff --git a/game/asset/dsl/file.go b/game/asset/dsl/file.go new file mode 100644 index 00000000..532e4d9e --- /dev/null +++ b/game/asset/dsl/file.go @@ -0,0 +1,30 @@ +package dsl + +import ( + "fmt" + "io" + "os" +) + +// StreamFile streams a binary file from the provided path. +func StreamFile(path string) Provider[io.ReadCloser] { + return OnceProvider(FuncProvider( + // get function + func() (io.ReadCloser, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open raw file %q: %w", path, err) + } + return file, nil + }, + + // digest function + func() ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat file %q: %w", path, err) + } + return CreateDigest("stream-file", path, info.ModTime()) + }, + )) +} diff --git a/game/asset/dsl/resource.go b/game/asset/dsl/resource.go index cea1f789..7c559313 100644 --- a/game/asset/dsl/resource.go +++ b/game/asset/dsl/resource.go @@ -1,8 +1,14 @@ package dsl -import "fmt" +import ( + "fmt" + "io" +) -var resourceProviders = make(map[string]Provider[any]) +var ( + resourceProviders = make(map[string]Provider[any]) + rawResourceProviders = make(map[string]Provider[io.ReadCloser]) +) // Save saves the specified resource to an asset at the specified path. func Save[T any](path string, provider Provider[T]) any { @@ -26,3 +32,26 @@ func Save[T any](path string, provider Provider[T]) any { )) return nil } + +// SaveRaw saves the specified raw binary resource to an asset at the specified path. +func SaveRaw(path string, provider Provider[io.ReadCloser]) any { + if _, ok := rawResourceProviders[path]; ok { + panic(fmt.Sprintf("provider for asset at path %q already exists", path)) + } + rawResourceProviders[path] = OnceProvider(FuncProvider( + // get function + func() (io.ReadCloser, error) { + in, err := provider.Get() + if err != nil { + return nil, fmt.Errorf("error getting resource: %w", err) + } + return in, nil + }, + + // digest function + func() ([]byte, error) { + return CreateDigest("save-raw-asset", path, provider) + }, + )) + return nil +} From 8349de0403dbfda08cb683da70668d55b84ca026 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Mon, 26 Jan 2026 16:23:48 +0200 Subject: [PATCH 09/59] Enhance audio API interface --- audio/api.go | 53 +++++++-- audio/node.go | 123 ++++++++++++++++++- audio/nop.go | 259 +++++++++++++++++++++++++++++++++++++---- audio/sample.go | 11 ++ ui/resource_manager.go | 2 +- 5 files changed, 406 insertions(+), 42 deletions(-) create mode 100644 audio/sample.go diff --git a/audio/api.go b/audio/api.go index 12fe5dcf..32f9cfc2 100644 --- a/audio/api.go +++ b/audio/api.go @@ -3,13 +3,27 @@ package audio // API provides access to a low-level audio manipulation and playback. type API interface { - // CreateMedia creates a new Media object based on the specified info. - CreateMedia(info MediaInfo) Media + // SampleRate returns the audio sample rate used by the API (i.e. how + // many samples there are in a single second). + SampleRate() int - // Play plays the specified media as soon as possible. + // CreateMedia creates a new Media object from the specified samples. This + // function assumes that the samples match the API's sample rate. // - // TODO: REMOVE THIS!!!! - Play(media Media, info PlayInfo) Playback + // Keep in mind that the implementation may keep a reference to the provided + // samples slice, so it should not be modified after being passed to this + // method. + CreateMedia(samples []Sample) Media + + // ParseMedia creates a new Media object based on the specified raw data info + // by parsing it according to its format. + ParseMedia(info MediaInfo) Media + + // Output returns the output audio node. + Output() Node + + // SpatialListener returns the spatial listener used for 3D audio. + SpatialListener() SpatialListener // CreatePlaybackNode creates a new playback node for the specified media. CreatePlaybackNode(media Media, loop bool) PlaybackNode @@ -26,6 +40,26 @@ type API interface { // CreateSpatialNode creates a new spatial audio node. CreateSpatialNode() SpatialNode + // CreateHighPassNode creates a new high-pass filter node. + CreateHighPassNode() HighPassNode + + // CreateLowPassNode creates a new low-pass filter node. + CreateLowPassNode() LowPassNode + + // CreateDelayNode creates a new delay node. + CreateDelayNode() DelayNode + + // CreateReverbNode creates a new reverb node. + CreateReverbNode() ReverbNode + + // CreateCompressorNode creates a new compressor node. + CreateCompressorNode() CompressorNode + + // CreateConnectorNode creates a new connector node. It is a no-op node that + // can be used to connect other nodes together without affecting the audio + // signal. + CreateConnectorNode() ConnectorNode + // Chain connects the specified nodes in sequence. This is a convenience // function that uses the Connect method of the API. Beware that it may // incur allocations due to variadic parameters. @@ -37,9 +71,8 @@ type API interface { // Disconnect disconnects the source node from the target node. Disconnect(source, target Node) - // SpatialListener returns the spatial listener used for 3D audio. - SpatialListener() SpatialListener - - // Output returns the output audio node. - Output() Node + // Play plays the specified media as soon as possible. + // + // TODO: REMOVE THIS!!!! + Play(media Media, info PlayInfo) Playback } diff --git a/audio/node.go b/audio/node.go index 0bd597ff..29b64ce7 100644 --- a/audio/node.go +++ b/audio/node.go @@ -11,7 +11,8 @@ type UserNode interface { Node // Delete releases any resources associated with the node. After calling - // this method, the node should not be used anymore. + // this method, the node should not be used anymore as it may be reused + // by the audio system. Delete() } @@ -20,13 +21,50 @@ type UserNode interface { type PlaybackNode interface { UserNode - // Loop returns true if the playback is set to loop when it reaches the end + // Start starts the playback of the audio. + // + // The offset parameter specifies the position in seconds from which to start + // the playback. + Start(offset float32) + + // Stop stops the playback of the audio. + Stop() + + // Resume resumes the playback of the audio. + // + // If the playback is already playing, this method has no effect. + // + // If the playback is stopped, this method has the same effect as Start with + // an offset of 0. + Resume() + + // Pause pauses the playback of the audio. + // + // If the playback is already paused or stopped, this method has no effect. + Pause() + + // IsPlaying returns true if the playback is currently playing. + IsPlaying() bool + + // IsLoop returns true if the playback is set to loop when it reaches the end // of the media. - Loop() bool + IsLoop() bool - // Done returns true if the playback has reached the end of the media - // and is not looping. - Done() bool + // SetLoop sets whether the playback should loop when it reaches the end of + // the media. + SetLoop(loop bool) + + // LoopStart returns the loop start position in seconds. + LoopStart() float32 + + // SetLoopStart sets the loop start position in seconds. + SetLoopStart(loopStart float32) + + // LoopEnd returns the loop end position in seconds. + LoopEnd() float32 + + // SetLoopEnd sets the loop end position in seconds. + SetLoopEnd(loopEnd float32) } // OscillatorNode represents an audio node that generates periodic waveforms. @@ -76,3 +114,76 @@ type SpatialNode interface { // SetPosition sets the 3D position of the audio source. SetPosition(position sprec.Vec3) } + +// HighPassNode represents an audio node that applies a high-pass filter to +// the audio signal. +type HighPassNode interface { + UserNode + + // CutoffFrequency returns the cutoff frequency of the high-pass filter in + // Hertz. + CutoffFrequency() float32 + + // SetCutoffFrequency sets the cutoff frequency of the high-pass filter in + // Hertz. + SetCutoffFrequency(frequency float32) +} + +// LowPassNode represents an audio node that applies a low-pass filter to +// the audio signal. +type LowPassNode interface { + UserNode + + // CutoffFrequency returns the cutoff frequency of the low-pass filter in + // Hertz. + CutoffFrequency() float32 + + // SetCutoffFrequency sets the cutoff frequency of the low-pass filter in + // Hertz. + SetCutoffFrequency(frequency float32) +} + +// DelayNode represents an audio node that applies a delay effect to the audio +// signal. +type DelayNode interface { + UserNode + + // DelayTime returns the delay time in seconds. + DelayTime() float32 + + // SetDelayTime sets the delay time in seconds. + // + // The maximum supported delay time may be limited by the implementation + // but should be at least 1 second. + SetDelayTime(delayTime float32) +} + +// ReverbNode represents an audio node that applies a reverb effect to the +// audio signal. +type ReverbNode interface { + UserNode + + // RoomSize returns the size of the virtual room for the reverb effect. + RoomSize() float32 + + // SetRoomSize sets the size of the virtual room for the reverb effect. + SetRoomSize(size float32) +} + +// CompressorNode represents an audio node that applies dynamic range +// compression to the audio signal. +type CompressorNode interface { + UserNode + + // Threshold returns the threshold level in decibels. + Threshold() float32 + + // SetThreshold sets the threshold level in decibels. + SetThreshold(threshold float32) +} + +// ConnectorNode represents a no-op audio node that can be used to connect +// other nodes together without affecting the audio signal. +type ConnectorNode interface { + UserNode +} diff --git a/audio/nop.go b/audio/nop.go index a4102577..a723e790 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -8,37 +8,79 @@ import ( // NewNopAPI returns an API that does nothing. func NewNopAPI() API { - return &nopAPI{} + return &nopAPI{ + listener: &nopListener{ + rotation: sprec.IdentityQuat(), + }, + } } -type nopAPI struct{} +type nopAPI struct { + listener *nopListener +} + +func (a *nopAPI) SampleRate() int { + return 44100 +} -func (a *nopAPI) CreateMedia(info MediaInfo) Media { +func (a *nopAPI) CreateMedia(samples []Sample) Media { return &nopMedia{} } -func (a *nopAPI) Play(media Media, info PlayInfo) Playback { - return &nopPlayback{} +func (a *nopAPI) ParseMedia(info MediaInfo) Media { + return &nopMedia{} } -func (a *nopAPI) CreatePlaybackNode(media Media, loop bool) PlaybackNode { +func (a *nopAPI) Output() Node { return nil } +func (a *nopAPI) SpatialListener() SpatialListener { + return a.listener +} + +func (a *nopAPI) CreatePlaybackNode(media Media, loop bool) PlaybackNode { + return &nopPlaybackNode{} +} + func (a *nopAPI) CreateOscillatorNode() OscillatorNode { - return nil + return &nopOscillatorNode{} } func (a *nopAPI) CreateGainNode() GainNode { - return nil + return &nopGainNode{} } func (a *nopAPI) CreatePanNode() PanNode { - return nil + return &nopPanNode{} } func (a *nopAPI) CreateSpatialNode() SpatialNode { - return nil + return &nopSpatialNode{} +} + +func (a *nopAPI) CreateHighPassNode() HighPassNode { + return &nopHighPassNode{} +} + +func (a *nopAPI) CreateLowPassNode() LowPassNode { + return &nopLowPassNode{} +} + +func (a *nopAPI) CreateDelayNode() DelayNode { + return &nopDelayNode{} +} + +func (a *nopAPI) CreateReverbNode() ReverbNode { + return &nopReverbNode{} +} + +func (a *nopAPI) CreateCompressorNode() CompressorNode { + return &nopCompressorNode{} +} + +func (a *nopAPI) CreateConnectorNode() ConnectorNode { + return &nopUserNode{} } func (a *nopAPI) Chain(nodes ...Node) {} @@ -47,12 +89,8 @@ func (a *nopAPI) Connect(source, target Node) {} func (a *nopAPI) Disconnect(source, target Node) {} -func (a *nopAPI) SpatialListener() SpatialListener { - return &nopListener{} -} - -func (a *nopAPI) Output() Node { - return nil +func (a *nopAPI) Play(media Media, info PlayInfo) Playback { + return &nopPlayback{} } type nopMedia struct{} @@ -63,20 +101,191 @@ func (m *nopMedia) Length() time.Duration { func (m *nopMedia) Delete() {} -type nopPlayback struct{} - -func (p *nopPlayback) Stop() {} - -type nopListener struct{} +type nopListener struct { + position sprec.Vec3 + rotation sprec.Quat +} func (l *nopListener) Position() sprec.Vec3 { - return sprec.ZeroVec3() + return l.position } -func (l *nopListener) SetPosition(position sprec.Vec3) {} +func (l *nopListener) SetPosition(position sprec.Vec3) { + l.position = position +} func (l *nopListener) Rotation() sprec.Quat { - return sprec.IdentityQuat() + return l.rotation +} + +func (l *nopListener) SetRotation(rotation sprec.Quat) { + l.rotation = rotation +} + +type nopUserNode struct{} + +func (n *nopUserNode) Delete() {} + +type nopPlaybackNode struct { + nopUserNode + loop bool + loopStart float32 + loopEnd float32 +} + +func (n *nopPlaybackNode) Start(offset float32) {} + +func (n *nopPlaybackNode) Stop() {} + +func (n *nopPlaybackNode) Resume() {} + +func (n *nopPlaybackNode) Pause() {} + +func (n *nopPlaybackNode) IsPlaying() bool { + return false +} + +func (n *nopPlaybackNode) IsLoop() bool { + return n.loop +} + +func (n *nopPlaybackNode) SetLoop(loop bool) { + n.loop = loop +} + +func (n *nopPlaybackNode) LoopStart() float32 { + return n.loopStart +} + +func (n *nopPlaybackNode) SetLoopStart(loopStart float32) { + n.loopStart = loopStart +} + +func (n *nopPlaybackNode) LoopEnd() float32 { + return n.loopEnd +} + +func (n *nopPlaybackNode) SetLoopEnd(loopEnd float32) { + n.loopEnd = loopEnd +} + +type nopOscillatorNode struct { + nopUserNode + frequency float32 +} + +func (n *nopOscillatorNode) Frequency() float32 { + return n.frequency +} + +func (n *nopOscillatorNode) SetFrequency(frequency float32) { + n.frequency = frequency +} + +type nopGainNode struct { + nopUserNode + gain float32 +} + +func (n *nopGainNode) Gain() float32 { + return n.gain +} + +func (n *nopGainNode) SetGain(gain float32) { + n.gain = gain +} + +type nopPanNode struct { + nopUserNode + pan float32 +} + +func (n *nopPanNode) Pan() float32 { + return n.pan +} + +func (n *nopPanNode) SetPan(pan float32) { + n.pan = pan +} + +type nopSpatialNode struct { + nopUserNode + position sprec.Vec3 +} + +func (n *nopSpatialNode) Position() sprec.Vec3 { + return n.position +} + +func (n *nopSpatialNode) SetPosition(position sprec.Vec3) { + n.position = position +} + +type nopHighPassNode struct { + nopUserNode + cutoffFrequency float32 } -func (l *nopListener) SetRotation(rotation sprec.Quat) {} +func (n *nopHighPassNode) CutoffFrequency() float32 { + return n.cutoffFrequency +} + +func (n *nopHighPassNode) SetCutoffFrequency(cutoffFrequency float32) { + n.cutoffFrequency = cutoffFrequency +} + +type nopLowPassNode struct { + nopUserNode + cutoffFrequency float32 +} + +func (n *nopLowPassNode) CutoffFrequency() float32 { + return n.cutoffFrequency +} + +func (n *nopLowPassNode) SetCutoffFrequency(cutoffFrequency float32) { + n.cutoffFrequency = cutoffFrequency +} + +type nopDelayNode struct { + nopUserNode + delayTime float32 +} + +func (n *nopDelayNode) DelayTime() float32 { + return n.delayTime +} + +func (n *nopDelayNode) SetDelayTime(delayTime float32) { + n.delayTime = delayTime +} + +type nopReverbNode struct { + nopUserNode + roomSize float32 +} + +func (n *nopReverbNode) RoomSize() float32 { + return n.roomSize +} + +func (n *nopReverbNode) SetRoomSize(roomSize float32) { + n.roomSize = roomSize +} + +type nopCompressorNode struct { + nopUserNode + threshold float32 +} + +func (n *nopCompressorNode) Threshold() float32 { + return n.threshold +} + +func (n *nopCompressorNode) SetThreshold(threshold float32) { + n.threshold = threshold +} + +type nopPlayback struct{} + +func (p *nopPlayback) Stop() {} diff --git a/audio/sample.go b/audio/sample.go new file mode 100644 index 00000000..e7f5b6f5 --- /dev/null +++ b/audio/sample.go @@ -0,0 +1,11 @@ +package audio + +// Sample represents a single audio sample with left and right channel data. +type Sample struct { + + // Left is the left channel sample value. + Left float32 + + // Right is the right channel sample value. + Right float32 +} diff --git a/ui/resource_manager.go b/ui/resource_manager.go index 00dcb5e3..2c38c54c 100644 --- a/ui/resource_manager.go +++ b/ui/resource_manager.go @@ -117,7 +117,7 @@ func (m *resourceManager) OpenSound(uri string) (*Sound, error) { return nil, fmt.Errorf("error reading resource: %w", err) } - media := m.audioAPI.CreateMedia(audio.MediaInfo{ + media := m.audioAPI.ParseMedia(audio.MediaInfo{ Data: data, DataType: audio.MediaDataTypeAuto, }) From 1c44f3334e737a08995d572bce626ae7933f3911 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 7 Feb 2026 19:57:07 +0200 Subject: [PATCH 10/59] Expose NopMedia --- audio/nop.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/audio/nop.go b/audio/nop.go index a723e790..8ef15a60 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -24,11 +24,11 @@ func (a *nopAPI) SampleRate() int { } func (a *nopAPI) CreateMedia(samples []Sample) Media { - return &nopMedia{} + return NewNopMedia() } func (a *nopAPI) ParseMedia(info MediaInfo) Media { - return &nopMedia{} + return NewNopMedia() } func (a *nopAPI) Output() Node { @@ -93,6 +93,10 @@ func (a *nopAPI) Play(media Media, info PlayInfo) Playback { return &nopPlayback{} } +func NewNopMedia() Media { + return &nopMedia{} +} + type nopMedia struct{} func (m *nopMedia) Length() time.Duration { From 4cb16beb4059c005b0d7bffdce8d1f6a3a4657f9 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Tue, 10 Feb 2026 19:00:27 +0200 Subject: [PATCH 11/59] Add audio sample utilities --- audio/sample.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/audio/sample.go b/audio/sample.go index e7f5b6f5..097e44b6 100644 --- a/audio/sample.go +++ b/audio/sample.go @@ -1,5 +1,11 @@ package audio +import ( + "math" + + "github.com/mokiat/gomath/sprec" +) + // Sample represents a single audio sample with left and right channel data. type Sample struct { @@ -9,3 +15,45 @@ type Sample struct { // Right is the right channel sample value. Right float32 } + +// SampleCount calculates the number of samples for a given duration in seconds +// given the used sample rate. +func SampleCount(seconds float32, sampleRate int) int { + return int(float32(sampleRate) * seconds) +} + +// Resample resamples the given audio samples from one sample rate to another. +func Resample(samples []Sample, fromRate int, toRate int) []Sample { + if (fromRate == toRate) || (len(samples) == 0) { + return samples + } + if fromRate <= 0 || toRate <= 0 { + panic("invalid sample rate") + } + oldLength := len(samples) + + scale := float64(toRate) / float64(fromRate) + newLength := int(float64(len(samples))*scale + 0.5) + if newLength <= 0 { + return nil + } + + result := make([]Sample, newLength) + step := float64(oldLength-1) / float64(newLength-1) + for i := range newLength { + srcPosition, srcFraction := math.Modf(float64(i) * step) + srcIndexPrev := min(int(srcPosition), oldLength-1) + srcIndexNext := min(srcIndexPrev+1, oldLength-1) + if srcIndexPrev == srcIndexNext { + result[i] = samples[srcIndexPrev] + } else { + prev := samples[srcIndexPrev] + next := samples[srcIndexNext] + result[i] = Sample{ + Left: sprec.Mix(prev.Left, next.Left, float32(srcFraction)), + Right: sprec.Mix(prev.Right, next.Right, float32(srcFraction)), + } + } + } + return result +} From afbb8233f17b984eb3abcc3c14ff95f6add23560 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 11 Feb 2026 19:12:56 +0200 Subject: [PATCH 12/59] Add gain-db utility functions --- audio/util.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 audio/util.go diff --git a/audio/util.go b/audio/util.go new file mode 100644 index 00000000..7da23bd7 --- /dev/null +++ b/audio/util.go @@ -0,0 +1,13 @@ +package audio + +import "math" + +// DBToGain converts a decibel value to a gain value. +func DBToGain(db float32) float32 { + return float32(math.Pow(10.0, float64(db/20.0))) +} + +// GainToDB converts a gain value to a decibel value. +func GainToDB(gain float32) float32 { + return float32(20.0 * math.Log10(float64(gain))) +} From 3d1784ae5f3c52bb7ebfe3151715e9fac8c9f3ea Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 23 Apr 2026 01:09:41 +0300 Subject: [PATCH 13/59] Bump Go version --- .github/workflows/go.yml | 2 +- go.mod | 22 +++++++++++----------- go.sum | 40 ++++++++++++++++++++-------------------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 47f3d793..6515437e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.26" - name: Run Tests run: go tool ginkgo -r -randomize-all diff --git a/go.mod b/go.mod index 9a42dfa2..dc69d349 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mokiat/lacking -go 1.25 +go 1.26 require ( github.com/google/uuid v1.6.0 @@ -8,13 +8,13 @@ require ( github.com/mokiat/gblob v0.5.0 github.com/mokiat/goexr v0.1.0 github.com/mokiat/gog v0.21.0 - github.com/mokiat/gomath v0.15.0 - github.com/onsi/ginkgo/v2 v2.27.2 - github.com/onsi/gomega v1.38.2 + github.com/mokiat/gomath v0.16.0 + github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/gomega v1.39.1 github.com/qmuntal/gltf v0.28.0 github.com/x448/float16 v0.8.4 golang.org/x/image v0.33.0 - golang.org/x/sync v0.18.0 + golang.org/x/sync v0.20.0 ) require ( @@ -23,16 +23,16 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect honnef.co/go/tools v0.6.1 // indirect diff --git a/go.sum b/go.sum index 4858dfe7..948f4574 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE= -github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -47,12 +47,12 @@ github.com/mokiat/goexr v0.1.0 h1:zoDvzvIjs/GpkxJDVcCP6GafLp1nOuNDef9JL8KSd2A= github.com/mokiat/goexr v0.1.0/go.mod h1:KhERYaXCcY2ZEaTg1/LyzJ7pxdj/q3V1TxgViG86ck4= github.com/mokiat/gog v0.21.0 h1:GfdsPpz/sHTip98Lhecv+6u+nVUpW6/v6bdUABSASF8= github.com/mokiat/gog v0.21.0/go.mod h1:1aiR6J14o060Xuuw5wbtR7itILqrsrItOuGGTSbWhRA= -github.com/mokiat/gomath v0.15.0 h1:ybgU/26TeV2UVgqOILD3DHbyVVtxMXFc7riVxzMIsqI= -github.com/mokiat/gomath v0.15.0/go.mod h1:o1np3H2xOC9nhaQ33FWY4xrUi2vPTGOfNKIKb0T1AtU= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/mokiat/gomath v0.16.0 h1:RST7h5F7+UGLs5EeOoeNVX5d2FQB50qmUYpJRmMIyi0= +github.com/mokiat/gomath v0.16.0/go.mod h1:ixaVMF1VIeH0C3oyIWEwUAxB3ggzzKgcoWZWOKe2DdU= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= 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/qmuntal/gltf v0.28.0 h1:C4A1temWMPtcI2+qNfpfRq8FEJxoBGUN3ZZM8BCc+xU= @@ -77,18 +77,18 @@ golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6 h1:8dPTIY8FDvi6k5 golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= From c7da727d25160f26e5f2402449ad96f81e945693 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 23 Apr 2026 01:10:51 +0300 Subject: [PATCH 14/59] Fix gomath changes and use Go native functions --- game/asset/conv/mesh.go | 3 +-- game/asset/dsl/digest.go | 16 ++++++++-------- game/graphics/light_point.go | 2 +- game/graphics/light_spot.go | 2 +- game/graphics/mesh.go | 2 +- ui/font.go | 4 ++-- util/mem/allocator.go | 2 +- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/game/asset/conv/mesh.go b/game/asset/conv/mesh.go index 61a64f1a..43a5271a 100644 --- a/game/asset/conv/mesh.go +++ b/game/asset/conv/mesh.go @@ -6,7 +6,6 @@ import ( "github.com/mokiat/gblob" "github.com/mokiat/gog" "github.com/mokiat/gog/ds" - "github.com/mokiat/gomath/dprec" "github.com/mokiat/lacking/game/asset/dto" "github.com/mokiat/lacking/game/asset/mdl" "github.com/mokiat/lacking/storage/chunked" @@ -280,7 +279,7 @@ func (c *MeshConverter) convertGeometry(geometry *mdl.Geometry) (dto.Geometry, e var boundingSphereRadius float64 for _, vertex := range geometry.Vertices() { - boundingSphereRadius = dprec.Max( + boundingSphereRadius = max( boundingSphereRadius, float64(vertex.Coord.Length()), ) diff --git a/game/asset/dsl/digest.go b/game/asset/dsl/digest.go index 13a6683f..84adaae8 100644 --- a/game/asset/dsl/digest.go +++ b/game/asset/dsl/digest.go @@ -98,23 +98,23 @@ func writeValue(out io.Writer, value any) error { case time.Time: io.WriteString(out, value.Format(time.RFC3339)) case sprec.Vec2: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case sprec.Vec3: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case sprec.Vec4: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case sprec.Quat: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case sprec.Angle: io.WriteString(out, fmt.Sprintf("%f", value)) case dprec.Vec2: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case dprec.Vec3: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case dprec.Vec4: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case dprec.Quat: - io.WriteString(out, value.GoString()) + io.WriteString(out, value.String()) case dprec.Angle: io.WriteString(out, fmt.Sprintf("%f", value)) case Digestable: diff --git a/game/graphics/light_point.go b/game/graphics/light_point.go index 3c3cfe38..95c40e9f 100644 --- a/game/graphics/light_point.go +++ b/game/graphics/light_point.go @@ -81,7 +81,7 @@ func (l *PointLight) EmitRange() float64 { // SetEmitRange changes the distance that this light source covers. func (l *PointLight) SetEmitRange(emitRange float64) { if emitRange != l.emitRange { - l.emitRange = dprec.Max(0.0, emitRange) + l.emitRange = max(0.0, emitRange) l.scene.pointLightSet.Update( l.itemID, l.position, l.emitRange, ) diff --git a/game/graphics/light_spot.go b/game/graphics/light_spot.go index cde33adb..a82d6f03 100644 --- a/game/graphics/light_spot.go +++ b/game/graphics/light_spot.go @@ -104,7 +104,7 @@ func (l *SpotLight) EmitRange() float64 { // SetEmitRange changes the distance that this light source covers. func (l *SpotLight) SetEmitRange(emitRange float64) { if emitRange != l.emitRange { - l.emitRange = dprec.Max(0.0, emitRange) + l.emitRange = max(0.0, emitRange) l.scene.spotLightSet.Update( l.itemID, l.position, l.emitRange, ) diff --git a/game/graphics/mesh.go b/game/graphics/mesh.go index 72da9943..60e1ef0e 100644 --- a/game/graphics/mesh.go +++ b/game/graphics/mesh.go @@ -85,7 +85,7 @@ type StaticMeshInfo struct { func createStaticMesh(scene *Scene, info StaticMeshInfo) { position := info.Matrix.Translation() scale := info.Matrix.Scale() - maxScale := dprec.Max(scale.X, dprec.Max(scale.Y, scale.Z)) + maxScale := max(scale.X, scale.Y, scale.Z) radius := info.Definition.geometry.boundingSphereRadius * maxScale meshIndex := uint32(len(scene.staticMeshes)) diff --git a/ui/font.go b/ui/font.go index b74129e3..a22f0302 100644 --- a/ui/font.go +++ b/ui/font.go @@ -115,7 +115,7 @@ func (f *Font) TextSize(text string, fontSize float32) sprec.Vec2 { continue } if ch == '\n' { - result.X = sprec.Max(result.X, currentWidth) + result.X = max(result.X, currentWidth) result.Y += f.lineHeight currentWidth = 0.0 lastGlyph = nil @@ -129,7 +129,7 @@ func (f *Font) TextSize(text string, fontSize float32) sprec.Vec2 { lastGlyph = glyph } } - result.X = sprec.Max(result.X, currentWidth) + result.X = max(result.X, currentWidth) return sprec.Vec2Prod(result, fontSize) } diff --git a/util/mem/allocator.go b/util/mem/allocator.go index 5521c6c9..012158ed 100644 --- a/util/mem/allocator.go +++ b/util/mem/allocator.go @@ -37,7 +37,7 @@ func NewSparseAllocator[T any]() *SparseAllocator[T] { func (a *SparseAllocator[T]) Allocate() *T { if a.pool.IsEmpty() { - return gog.PtrOf(gog.Zero[T]()) + return new(gog.Zero[T]()) } return a.pool.Pop() } From 00227607049e2553264f0faa31b4fa3bf2530bee Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 23 Apr 2026 22:24:37 +0300 Subject: [PATCH 15/59] Mark audio node interfaces --- audio/node.go | 4 +++- audio/nop.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/audio/node.go b/audio/node.go index 29b64ce7..d56da427 100644 --- a/audio/node.go +++ b/audio/node.go @@ -4,7 +4,9 @@ import "github.com/mokiat/gomath/sprec" // Node represents a node in a chain of audio elements. Each node produces // audio data which can be synthesized, processed, or played back. -type Node any +type Node interface { + _isAudioNode() // marker method +} // UserNode represents an audio node that requires explicit resource management. type UserNode interface { diff --git a/audio/nop.go b/audio/nop.go index 8ef15a60..815f42d3 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -126,7 +126,9 @@ func (l *nopListener) SetRotation(rotation sprec.Quat) { l.rotation = rotation } -type nopUserNode struct{} +type nopUserNode struct { + Node // marker interface +} func (n *nopUserNode) Delete() {} From 71f65ae215a1d25cf0fde9b3a992fe847f746480 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 23 Apr 2026 23:19:25 +0300 Subject: [PATCH 16/59] Document Media ownership in PlaybackNode --- audio/api.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/audio/api.go b/audio/api.go index 32f9cfc2..7d12ff65 100644 --- a/audio/api.go +++ b/audio/api.go @@ -26,6 +26,8 @@ type API interface { SpatialListener() SpatialListener // CreatePlaybackNode creates a new playback node for the specified media. + // + // It is safe to delete the media after creating the playback node. CreatePlaybackNode(media Media, loop bool) PlaybackNode // CreateOscillatorNode creates a new oscillator node. From 01825bb7845d2acb7f7dad673668301666534c4c Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 26 Apr 2026 17:37:30 +0300 Subject: [PATCH 17/59] Ignore pprof files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f288d6aa..de32c738 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ # Visual Studio Code .vscode/ + +# PProf +cpu.pprof +mem.pprof From f0e3c80ee22572efb19287582023d395cb917f22 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 26 Apr 2026 17:37:57 +0300 Subject: [PATCH 18/59] Document single-threadedness of audio API --- audio/api.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/audio/api.go b/audio/api.go index 7d12ff65..1a7c803f 100644 --- a/audio/api.go +++ b/audio/api.go @@ -1,6 +1,8 @@ package audio // API provides access to a low-level audio manipulation and playback. +// +// All functions in this API need to be called from the main thread. type API interface { // SampleRate returns the audio sample rate used by the API (i.e. how From fe1edb83c6c65f8aa06021a66a86a7146151e75b Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Tue, 28 Apr 2026 23:04:24 +0300 Subject: [PATCH 19/59] Bump gog package --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dc69d349..d154565c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/mdouchement/hdr v0.2.4 github.com/mokiat/gblob v0.5.0 github.com/mokiat/goexr v0.1.0 - github.com/mokiat/gog v0.21.0 + github.com/mokiat/gog v0.22.0 github.com/mokiat/gomath v0.16.0 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 diff --git a/go.sum b/go.sum index 948f4574..71f51649 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,8 @@ github.com/mokiat/gblob v0.5.0 h1:CF4/aWavIvR2sjioEQSMktvmivu8H2OQ1lnbh6poICI= github.com/mokiat/gblob v0.5.0/go.mod h1:lG1XkaKsZ06gAxuBPChKLrKXKzOA4xnIZtGFg7VoFBk= github.com/mokiat/goexr v0.1.0 h1:zoDvzvIjs/GpkxJDVcCP6GafLp1nOuNDef9JL8KSd2A= github.com/mokiat/goexr v0.1.0/go.mod h1:KhERYaXCcY2ZEaTg1/LyzJ7pxdj/q3V1TxgViG86ck4= -github.com/mokiat/gog v0.21.0 h1:GfdsPpz/sHTip98Lhecv+6u+nVUpW6/v6bdUABSASF8= -github.com/mokiat/gog v0.21.0/go.mod h1:1aiR6J14o060Xuuw5wbtR7itILqrsrItOuGGTSbWhRA= +github.com/mokiat/gog v0.22.0 h1:LUfqgvJHpUlre5JVx10fsipHnqo5fmCEiZ2RWBlNgG4= +github.com/mokiat/gog v0.22.0/go.mod h1:0tl6srnQjC9ZYKAQkvLrrXMblFMmqqjoOWLm+LkRAEo= github.com/mokiat/gomath v0.16.0 h1:RST7h5F7+UGLs5EeOoeNVX5d2FQB50qmUYpJRmMIyi0= github.com/mokiat/gomath v0.16.0/go.mod h1:ixaVMF1VIeH0C3oyIWEwUAxB3ggzzKgcoWZWOKe2DdU= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= From 07f338eefcb0f510cb2115e8457c408439acbeac Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 19:07:53 +0300 Subject: [PATCH 20/59] Introduce new ECS implementation (#67) --- docs/manual/ecs/index.md | 355 ++++++++ game/controller.go | 13 - game/ecs/bench_test.go | 269 +++--- game/ecs/callback.go | 16 + game/ecs/component.go | 81 +- game/ecs/component_dense.go | 53 -- game/ecs/component_sparse.go | 64 -- game/ecs/component_tiny.go | 71 -- game/ecs/condition.go | 65 ++ game/ecs/doc.go | 30 + game/ecs/engine.go | 48 - game/ecs/entity.go | 72 -- game/ecs/id.go | 23 + game/ecs/internal/archetype.go | 131 +++ game/ecs/internal/buffer.go | 58 ++ game/ecs/internal/chunk.go | 5 + game/ecs/internal/column.go | 93 ++ game/ecs/internal/command.go | 44 + game/ecs/internal/component_type.go | 127 +++ game/ecs/internal/component_type_test.go | 146 +++ game/ecs/internal/entity.go | 65 ++ game/ecs/internal/id.go | 19 + game/ecs/internal/registry.go | 32 + game/ecs/internal/row.go | 4 + game/ecs/internal/stager.go | 67 ++ game/ecs/internal/storage.go | 123 +++ game/ecs/internal/suite_test.go | 13 + game/ecs/operation.go | 107 +++ game/ecs/query.go | 120 --- game/ecs/scene.go | 704 ++++++++++++--- game/ecs/scene_test.go | 1039 ++++++++++++++++++++++ game/ecs/subscription.go | 9 - game/ecs/suite_test.go | 13 + game/engine.go | 12 - game/scene.go | 20 +- mkdocs.yml | 28 + 36 files changed, 3420 insertions(+), 719 deletions(-) create mode 100644 docs/manual/ecs/index.md create mode 100644 game/ecs/callback.go delete mode 100644 game/ecs/component_dense.go delete mode 100644 game/ecs/component_sparse.go delete mode 100644 game/ecs/component_tiny.go create mode 100644 game/ecs/condition.go create mode 100644 game/ecs/doc.go delete mode 100644 game/ecs/engine.go delete mode 100644 game/ecs/entity.go create mode 100644 game/ecs/id.go create mode 100644 game/ecs/internal/archetype.go create mode 100644 game/ecs/internal/buffer.go create mode 100644 game/ecs/internal/chunk.go create mode 100644 game/ecs/internal/column.go create mode 100644 game/ecs/internal/command.go create mode 100644 game/ecs/internal/component_type.go create mode 100644 game/ecs/internal/component_type_test.go create mode 100644 game/ecs/internal/entity.go create mode 100644 game/ecs/internal/id.go create mode 100644 game/ecs/internal/registry.go create mode 100644 game/ecs/internal/row.go create mode 100644 game/ecs/internal/stager.go create mode 100644 game/ecs/internal/storage.go create mode 100644 game/ecs/internal/suite_test.go create mode 100644 game/ecs/operation.go delete mode 100644 game/ecs/query.go create mode 100644 game/ecs/scene_test.go delete mode 100644 game/ecs/subscription.go create mode 100644 game/ecs/suite_test.go diff --git a/docs/manual/ecs/index.md b/docs/manual/ecs/index.md new file mode 100644 index 00000000..1b473f05 --- /dev/null +++ b/docs/manual/ecs/index.md @@ -0,0 +1,355 @@ +--- +title: Overview +--- + +# ECS + +The `game/ecs` package provides an Entity-Component System (ECS) framework for managing game-world objects and their data. It is archetype-based, meaning entities that share the same set of component types are stored together for efficient iteration. + +## Core Concepts + +| Concept | Description | +|---|---| +| **Scope** | Registry of component types. Shared across scenes that use the same component vocabulary. | +| **Scene** | Central container for entities and their components. | +| **ID** | Versioned handle to a live entity. Becomes stale automatically when the entity is deleted. | +| **Component** | Plain Go struct value attached to an entity. | +| **ComponentType** | Typed descriptor for a component, obtained at registration time. | +| **Condition** | Predicate over an entity's component set, used for queries and subscriptions. | + +## Setup + +### Registering Component Types + +Component types are registered in a `Scope` before any `Scene` is created from it. Registration is typically done with package-level variables so that `ComponentType` descriptors are accessible throughout the codebase. + +```go +var scope = ecs.NewScope() + +var ( + PositionType = ecs.Type[Position](scope) + VelocityType = ecs.Type[Velocity](scope) + HealthType = ecs.Type[Health](scope) +) + +type Position struct { + X, Y, Z float32 +} + +type Velocity struct { + X, Y, Z float32 +} + +type Health struct { + Current, Max int +} +``` + +A scope is locked once it is passed to `NewScene`. Attempting to register additional types after that point panics. A single scope can back multiple scenes, but each scene maintains its own independent entity table. + +> **Limit:** A scope supports at most 256 component types. + +### Creating a Scene + +```go +scene := ecs.NewScene(scope) +defer scene.Delete() +``` + +Call `scene.Delete()` when the scene is no longer needed to release all resources. + +## Entities + +### Creating Entities + +`CreateEntity` allocates a new entity and returns its `ID`. Pass a callback to add initial components atomically: + +```go +id := scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, PositionType, Position{X: 1, Y: 0, Z: 0}) + ecs.AddComponent(op, VelocityType, Velocity{X: 0, Y: 0, Z: 5}) +}) +``` + +Pass `nil` to create an entity with no components: + +```go +id := scene.CreateEntity(nil) +``` + +### Deleting Entities + +```go +scene.DeleteEntity(id) +``` + +After deletion the `ID` becomes stale and should not be used. Few methods in the library (e.g. `HasEntity`) accept a stale ID and won't panic. + +### Checking Existence and Component Membership + +```go +alive := scene.HasEntity(id) + +isPhysical := scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(VelocityType), +)) +``` + +## Reading Component Data + +### Reading a Single Entity + +`ReadEntity` calls the provided function with a `ReadOperation` scoped to that entity. The operation is valid only for the duration of the call. + +```go +scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos := ecs.GetComponent(op, PositionType) // returns *Position or nil + if pos != nil { + fmt.Println(pos.X, pos.Y, pos.Z) + } +}) +``` + +`GetComponent` returns a pointer to the component value, or `nil` if the entity does not have that component. `InjectComponent` is a convenience wrapper that writes the pointer into a variable: + +```go +scene.ReadEntity(id, func(op *ecs.ReadOperation) { + var pos *Position + ecs.InjectComponent(op, PositionType, &pos) + if pos != nil { + // use pos + } +}) +``` + +### Querying Multiple Entities + +`QueryEntities` iterates every entity that satisfies a condition. Return `false` from the callback to stop early. + +```go +movingEntities := ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(VelocityType), +) + +scene.QueryEntities(movingEntities, func(id ecs.ID, op *ecs.ReadOperation) bool { + pos := ecs.GetComponent(op, PositionType) + vel := ecs.GetComponent(op, VelocityType) + fmt.Printf("%v: pos=%v vel=%v\n", id, pos, vel) + return true // continue +}) +``` + +`QueryEntitiesIter` provides the same traversal as a Go range iterator: + +```go +for id, op := range scene.QueryEntitiesIter(movingEntities) { + pos := ecs.GetComponent(op, PositionType) + _ = pos +} +``` + +## Editing Component Data + +### Editing a Single Entity + +`EditEntity` calls the provided function with an `EditOperation` for the entity. Three operations are available: + +| Function | Effect | +|---|---| +| `AddComponent` | Adds a new component. Panics if the entity already has one of that type. | +| `RemoveComponent` | Removes an existing component. Panics if the entity does not have one. | +| `ReplaceComponent` | Updates the value of an existing component without changing the entity's component set. | + +```go +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, HealthType, Health{Current: 100, Max: 100}) +}) + +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.ReplaceComponent(op, VelocityType, Velocity{X: 0, Y: 10, Z: 0}) +}) + +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, VelocityType) +}) +``` + +Multiple operations can be staged in a single `EditEntity` call: + +```go +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, VelocityType) + ecs.AddComponent(op, HealthType, Health{Current: 50, Max: 100}) +}) +``` + +> `ReplaceComponent` is more efficient than a remove-then-add sequence when only the value needs to change, because it does not move the entity to a different archetype. + +## Conditions + +Conditions are predicates over an entity's component set. They are used for queries, subscriptions, and `CheckEntity`. + +```go +// Entity must have Position. +ecs.HasComponent(PositionType) + +// Entity must not have Velocity. +ecs.LacksComponent(VelocityType) + +// Entity must have Position and Health, but not Velocity. +ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(HealthType), + ecs.LacksComponent(VelocityType), +) +``` + +`Conditions` panics if the combined condition is contradictory (e.g., `HasComponent` and `LacksComponent` for the same type). + +### Exclusive Conditions + +`Exclusive()` derives a condition that additionally requires the entity to have *no other components* beyond those already required. It is useful for targeting a very specific archetype: + +```go +// Entity must have exactly Position and Velocity, and nothing else. +exact := ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(VelocityType), +).Exclusive() +``` + +## Subscriptions + +Subscriptions fire a callback whenever an entity transitions into or out of satisfying a condition. This is useful for initialising or tearing down subsystem resources (e.g., physics bodies, render objects) in response to component changes. + +```go +// Called when an entity gains both Position and Velocity. +sub := scene.SubscribeEnter( + ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(VelocityType), + ), + func(id ecs.ID) { + fmt.Println("entity became dynamic:", id) + }, +) + +// Called when an entity no longer satisfies the condition. +scene.SubscribeExit( + ecs.Conditions( + ecs.HasComponent(PositionType), + ecs.HasComponent(VelocityType), + ), + func(id ecs.ID) { + fmt.Println("entity left dynamic group:", id) + }, +) + +// Cancel a subscription when it is no longer needed. +sub.Delete() +``` + +Callbacks are dispatched after structural changes are committed, not inline during the mutation. They fire in the order the subscriptions were registered; there is no priority mechanism. + +## Deferred Mutations During Queries + +Structural changes — `CreateEntity`, `DeleteEntity`, and `EditEntity` calls that add or remove components — are safe to make during a query. They are buffered and applied once iteration completes. + +```go +toDelete := make([]ecs.ID, 0) + +scene.QueryEntities(ecs.HasComponent(HealthType), func(id ecs.ID, op *ecs.ReadOperation) bool { + h := ecs.GetComponent(op, HealthType) + if h.Current <= 0 { + toDelete = append(toDelete, id) + } + return true +}) + +for _, id := range toDelete { + scene.DeleteEntity(id) +} +``` + +Alternatively, `DeleteEntity` (and `EditEntity`) may be called directly inside the query callback — the deletion will be buffered and executed after the query finishes: + +```go +scene.QueryEntities(ecs.HasComponent(HealthType), func(id ecs.ID, op *ecs.ReadOperation) bool { + h := ecs.GetComponent(op, HealthType) + if h.Current <= 0 { + scene.DeleteEntity(id) // safe; deferred until query completes + } + return true +}) +``` + +## Retaining Component Pointers with Freeze and Unfreeze + +`GetComponent` returns a pointer directly into the scene's component storage. Within a `ReadEntity` or `QueryEntities` callback the pointer is always valid, but retaining it past the callback is only safe while no structural mutations (add or remove component, delete entity) are committed, since those operations may move the entity to a different archetype and invalidate the pointer. + +`Freeze` and `Unfreeze` provide a bracket for exactly this use case. While the scene is frozen, all structural mutations are accepted and buffered but not applied. When `Unfreeze` is called the buffer is flushed. Any pointers retained during the freeze must be released before calling `Unfreeze`. + +```go +scene.Freeze() + +var pos *Position +scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) +}) + +// pos is safe to use here; any mutations are deferred. +doSomethingWith(pos) + +scene.Unfreeze() // buffered mutations are committed; do not use pos after this +``` + +`Freeze` calls may be nested. Each call increments an internal depth counter; mutations are committed only when the depth returns to zero. Every `Freeze` must be paired with exactly one `Unfreeze` — an unbalanced `Unfreeze` panics. + +```go +scene.Freeze() // depth → 1 +scene.Freeze() // depth → 2 + +// ... retain pointers, do work ... + +scene.Unfreeze() // depth → 1, mutations still deferred +scene.Unfreeze() // depth → 0, mutations committed +``` + +### Creating Entities While Frozen + +`CreateEntity`, `DeleteEntity`, and `EditEntity` can all be called while the scene is frozen — their effects are simply deferred. However, entities created while frozen are not yet committed to any archetype. Their IDs are valid for `HasEntity`, `DeleteEntity`, and `EditEntity`, but calling `ReadEntity` or `CheckEntity` on them before `Unfreeze` panics. + +```go +scene.Freeze() +id := scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) +}) + +scene.HasEntity(id) // true +scene.CheckEntity(id, ecs.HasComponent(positionType)) // panics — not yet committed +scene.ReadEntity(id, ...) // panics — not yet committed + +scene.Unfreeze() + +scene.CheckEntity(id, ecs.HasComponent(positionType)) // true — now committed +``` + +### Subscription Dispatch While Frozen + +Enter and exit subscription callbacks are part of the commit process. They are not fired during a buffered mutation — they fire when `Unfreeze` (or the end of a query) triggers the flush. + +## Systems + +This package does not define a system interface or a scheduler. System ordering, execution, and lifecycle management are the responsibility of the consuming application. + +## Limitations + +The following features are not currently provided by this ECS implementation: + +- **No change detection.** There is no built-in mechanism to query only entities whose component data changed since the last frame. Systems must iterate all matching entities unconditionally. +- **No parallel queries.** The scene is not thread-safe. All operations on a scene must occur from a single goroutine. `Freeze`/`Unfreeze` do not change this — they defer commits, not concurrent access. +- **No system scheduler.** Ordering and parallelism are entirely up to the application. +- **No entity relations.** Modelling parent–child or other entity-to-entity relationships requires external bookkeeping (the `game/hierarchy` package may be used for scene-node hierarchies). +- **No prefabs or entity templates.** There is no built-in way to stamp out entities from a template; construction helpers must be written by the application. diff --git a/game/controller.go b/game/controller.go index b17530dc..c1d6f058 100644 --- a/game/controller.go +++ b/game/controller.go @@ -3,7 +3,6 @@ package game import ( "github.com/mokiat/lacking/app" "github.com/mokiat/lacking/debug/metric" - "github.com/mokiat/lacking/game/ecs" "github.com/mokiat/lacking/game/graphics" "github.com/mokiat/lacking/game/physics" "github.com/mokiat/lacking/resource" @@ -38,9 +37,6 @@ type Controller struct { gfxOptions []graphics.Option gfxEngine *graphics.Engine - ecsOptions []ecs.Option - ecsEngine *ecs.Engine - physicsOptions []physics.Option physicsEngine *physics.Engine @@ -63,13 +59,6 @@ func (c *Controller) UseGraphicsOptions(opts ...graphics.Option) { c.gfxOptions = opts } -// UseECSOptions allows to specify options that will be used -// when initializing the ECS engine. This method should be -// called before the controller is initialized by the app framework. -func (c *Controller) UseECSOptions(opts ...ecs.Option) { - c.ecsOptions = opts -} - // UsePhysicsOptions allows to specify options that will be used // when initializing the physics engine. This method should be // called before the controller is initialized by the app framework. @@ -88,7 +77,6 @@ func (c *Controller) Engine() *Engine { func (c *Controller) OnCreate(window app.Window) { c.window = window c.gfxEngine = graphics.NewEngine(window.RenderAPI(), c.shaders, c.shaderBuilder, c.gfxOptions...) - c.ecsEngine = ecs.NewEngine(c.ecsOptions...) c.physicsEngine = physics.NewEngine(c.physicsOptions...) c.ioWorker = async.NewWorker(4) @@ -99,7 +87,6 @@ func (c *Controller) OnCreate(window app.Window) { WithIOWorker(c.ioWorker), WithStore(c.store), WithGraphics(c.gfxEngine), - WithECS(c.ecsEngine), WithPhysics(c.physicsEngine), ) c.engine.Create() diff --git a/game/ecs/bench_test.go b/game/ecs/bench_test.go index d1f82480..90f9e89e 100644 --- a/game/ecs/bench_test.go +++ b/game/ecs/bench_test.go @@ -1,161 +1,182 @@ package ecs_test import ( - "strconv" "testing" "github.com/mokiat/lacking/game/ecs" ) -type NameComponent struct { - name string -} - -func (c *NameComponent) SetName(name string) { - c.name = name -} - -func (c *NameComponent) Name() string { - return c.name -} - -type AgeComponent struct { - age int -} - -func (c *AgeComponent) SetAge(age int) { - c.age = age -} - -func (c *AgeComponent) Age() int { - return c.age -} - -func BenchmarkQueryDense(b *testing.B) { - engine := ecs.NewEngine() - scene := engine.CreateScene() - - nameComponents := ecs.NewDenseComponentSet[NameComponent](scene) - ageComponents := ecs.NewDenseComponentSet[AgeComponent](scene) +func BenchmarkCheckEntity(b *testing.B) { + type Position struct { + X, Y float64 + } + type Name struct { + Value string + } + type Age struct { + Value int + } - for i := range scene.MaxEntityCount() { - entity := scene.CreateEntity() - nameComponents.Set(entity, NameComponent{ - name: strconv.Itoa(i), + scope := ecs.NewScope() + positionType := ecs.Type[Position](scope) + nameType := ecs.Type[Name](scope) + ageType := ecs.Type[Age](scope) + scene := ecs.NewScene(scope) + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{ + X: 1.0, + Y: 2.0, }) - if i%2 == 0 { - ageComponents.Set(entity, AgeComponent{ - age: i, - }) + ecs.AddComponent(op, nameType, Name{ + Value: "Alice", + }) + }) + + for b.Loop() { + ok := scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(nameType), + ecs.LacksComponent(ageType), + )) + if !ok { + b.Fatal("unexpected failed check") } } +} - scene.Query().Release() // prepare cache - - type FakeType struct { - *NameComponent - *AgeComponent +func BenchmarkEditEntity(b *testing.B) { + type Position struct { + X, Y float64 + } + type Name struct { + Value string + } + type Age struct { + Value int } + scope := ecs.NewScope() + positionType := ecs.Type[Position](scope) + nameType := ecs.Type[Name](scope) + ageType := ecs.Type[Age](scope) + scene := ecs.NewScene(scope) + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{ + X: 1.0, + Y: 2.0, + }) + ecs.AddComponent(op, ageType, Age{ + Value: 30, + }) + }) + for b.Loop() { - result := scene.Query( - ecs.HasComponent(nameComponents), - ecs.HasComponent(ageComponents), - ) - result.Each(func(entity ecs.EntityID) { - obj := FakeType{ - NameComponent: nameComponents.Ref(entity), - AgeComponent: ageComponents.Ref(entity), - } - obj.SetName("test") - obj.SetAge(37) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, nameType, Name{ + Value: "Alice", + }) + }) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, nameType) }) - result.Release() } } -func BenchmarkQuerySparse(b *testing.B) { - engine := ecs.NewEngine() - scene := engine.CreateScene() - - nameComponents := ecs.NewSparseComponentSet[NameComponent](scene) - ageComponents := ecs.NewSparseComponentSet[AgeComponent](scene) - - for i := range scene.MaxEntityCount() { - entity := scene.CreateEntity() - nameComponents.Set(entity, NameComponent{ - name: strconv.Itoa(i), - }) - if i%2 == 0 { - ageComponents.Set(entity, AgeComponent{ - age: i, - }) - } +func BenchmarkQueryEntities(b *testing.B) { + type Position struct { + X, Y float64 + } + type Velocity struct { + X, Y float64 } - scene.Query().Release() // prepare cache + const entityCount = 1000 - type FakeType struct { - *NameComponent - *AgeComponent + scope := ecs.NewScope() + positionType := ecs.Type[Position](scope) + velocityType := ecs.Type[Velocity](scope) + scene := ecs.NewScene(scope) + + for range entityCount { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1.0, Y: 2.0}) + ecs.AddComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) + }) } for b.Loop() { - result := scene.Query( - ecs.HasComponent(nameComponents), - ecs.HasComponent(ageComponents), + count := 0 + scene.QueryEntities( + ecs.Conditions( + ecs.HasComponent(positionType), + ), + func(_ ecs.ID, op *ecs.ReadOperation) bool { + pos := ecs.GetComponent(op, positionType) + vel := ecs.GetComponent(op, velocityType) + pos.X += vel.X + pos.Y += vel.Y + count++ + return true + }, ) - result.Each(func(entity ecs.EntityID) { - obj := FakeType{ - NameComponent: nameComponents.Ref(entity), - AgeComponent: ageComponents.Ref(entity), - } - obj.SetName("test") - obj.SetAge(37) - }) - result.Release() + if count != entityCount { + b.Fatalf("unexpected entity count: got %d, want %d", count, entityCount) + } } } -func BenchmarkQueryTiny(b *testing.B) { - engine := ecs.NewEngine() - scene := engine.CreateScene() - - nameComponents := ecs.NewTinyComponentSet[NameComponent](scene) - ageComponents := ecs.NewTinyComponentSet[AgeComponent](scene) - - for i := range scene.MaxEntityCount() { - entity := scene.CreateEntity() - nameComponents.Set(entity, NameComponent{ - name: strconv.Itoa(i), - }) - if i%2 == 0 { - ageComponents.Set(entity, AgeComponent{ - age: i, - }) - } +func BenchmarkQueryEntitiesMultiArchetype(b *testing.B) { + type Position struct { + X, Y float64 } - - scene.Query().Release() // prepare cache - - type FakeType struct { - *NameComponent - *AgeComponent + type Velocity struct { + X, Y float64 + } + type Tag struct{} + + const entityCount = 1000 + + scope := ecs.NewScope() + positionType := ecs.Type[Position](scope) + velocityType := ecs.Type[Velocity](scope) + tagType := ecs.Type[Tag](scope) + scene := ecs.NewScene(scope) + + for i := range entityCount { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1.0, Y: 2.0}) + ecs.AddComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) + if i%2 == 0 { + ecs.AddComponent(op, tagType, Tag{}) + } + }) } for b.Loop() { - result := scene.Query( - ecs.HasComponent(nameComponents), - ecs.HasComponent(ageComponents), + count := 0 + scene.QueryEntities( + ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(velocityType), + ecs.HasComponent(tagType), + ), + func(_ ecs.ID, op *ecs.ReadOperation) bool { + pos := ecs.GetComponent(op, positionType) + vel := ecs.GetComponent(op, velocityType) + pos.X += vel.X + pos.Y += vel.Y + count++ + return true + }, ) - result.Each(func(entity ecs.EntityID) { - obj := FakeType{ - NameComponent: nameComponents.Ref(entity), - AgeComponent: ageComponents.Ref(entity), - } - obj.SetName("test") - obj.SetAge(37) - }) - result.Release() + if count != entityCount/2 { + b.Fatalf("unexpected entity count: got %d, want %d", count, entityCount/2) + } } } diff --git a/game/ecs/callback.go b/game/ecs/callback.go new file mode 100644 index 00000000..2e8da195 --- /dev/null +++ b/game/ecs/callback.go @@ -0,0 +1,16 @@ +package ecs + +import "github.com/mokiat/lacking/util/observer" + +// EntityCallback is a function invoked when an entity event fires. +// The id parameter identifies the entity that triggered the event. +type EntityCallback func(ID) + +type ConditionalCallback struct { + condition Condition + callback EntityCallback +} + +// EntitySubscription is a handle to a registered enter or exit +// callback. Call its Delete method to cancel the subscription. +type EntitySubscription = observer.Subscription[ConditionalCallback] diff --git a/game/ecs/component.go b/game/ecs/component.go index eeddf730..b369a44a 100644 --- a/game/ecs/component.go +++ b/game/ecs/component.go @@ -1,17 +1,74 @@ package ecs -// MaxComponentCount returns the maximum number of components that can be -// created per scene. +import ( + "reflect" + + "github.com/mokiat/gog/ds" + "github.com/mokiat/lacking/game/ecs/internal" +) + +// NewScope creates a new component type registry. +// +// Once a scope is passed to [NewScene] it is locked; attempting to +// register additional component types afterwards will panic. +func NewScope() *Scope { + return &Scope{ + registeredTypes: ds.EmptySet[reflect.Type](), + registry: internal.NewRegistry(), + } +} + +// Scope holds the component type registry shared by a set of scenes. +// Create one with [NewScope] and register component types with [Type]. +type Scope struct { + registeredTypes *ds.Set[reflect.Type] + registry *internal.Registry + inUse bool +} + +func (s *Scope) markInUse() { + s.inUse = true +} + +// Type registers the Go type T as a component type within scope and +// returns a [ComponentType] descriptor. The descriptor is used in API +// calls such as [AddComponent], [RemoveComponent], and [GetComponent]. // -// Unlike the max entity count, this number is not configurable. -const MaxComponentCount = 64 - -// ComponentSet represents a storage for components of the same type. -type ComponentSet[T any] interface { - Set(entityID EntityID, value T) - Unset(entityID EntityID) - Ref(entityID EntityID) *T - Mask() componentMask +// Call this function once per type, typically from a package-level var +// initializer. It is not safe for concurrent use. +func Type[T any](scope *Scope) ComponentType[T] { + if scope.inUse { + panic("cannot register component type in a scope that is already in use") + } + + initialCount := scope.registeredTypes.Size() + + if initialCount >= internal.MaxComponentTypes { + panic("too many component types registered in this scope") + } + + reflectType := reflect.TypeFor[T]() + if scope.registeredTypes.Contains(reflectType) { + panic("component type already registered in this scope") + } + scope.registeredTypes.Add(reflectType) + + id := internal.TypeID(initialCount) + + storage := internal.NewStorage[T]() + scope.registry.SetStorage(id, storage) + + return ComponentType[T]{ + id: id, + storage: storage, + } } -type componentMask uint64 +// ComponentType is the typed descriptor for a component of type T. +// Obtain one by calling [Type] and pass it to API functions such as +// [AddComponent], [GetComponent], and condition helpers like +// [HasComponent]. +type ComponentType[T any] struct { + id internal.TypeID + storage *internal.Storage[T] +} diff --git a/game/ecs/component_dense.go b/game/ecs/component_dense.go deleted file mode 100644 index dc609869..00000000 --- a/game/ecs/component_dense.go +++ /dev/null @@ -1,53 +0,0 @@ -package ecs - -import ( - "github.com/mokiat/gog" -) - -// NewDenseComponentSet returns a ComponentSet implementation that has -// pre-allocated storage for the maximum number of entities. -// -// While this implementation is the fastest available, it is also the most -// memory intensive and should be used only for components that are very -// common and are likely to be attached to the majority of entities. -func NewDenseComponentSet[T any](scene *Scene) *DenseComponentSet[T] { - result := &DenseComponentSet[T]{ - scene: scene, - mask: scene.newComponentType(), - components: make([]T, scene.MaxEntityCount()), - } - scene.purgeSubscriptions.Subscribe(result.Unset) - return result -} - -var _ ComponentSet[any] = (*DenseComponentSet[any])(nil) - -type DenseComponentSet[T any] struct { - scene *Scene - mask componentMask - components []T -} - -func (s *DenseComponentSet[T]) Set(entityID EntityID, value T) { - s.scene.assignComponent(entityID, s.mask) - - s.components[entityID.index] = value -} - -func (s *DenseComponentSet[T]) Unset(entityID EntityID) { - s.scene.removeComponent(entityID, s.mask) - - s.components[entityID.index] = gog.Zero[T]() -} - -func (s *DenseComponentSet[T]) Ref(entityID EntityID) *T { - if !s.scene.hasComponent(entityID, s.mask) { - return nil - } - - return &s.components[entityID.index] -} - -func (s *DenseComponentSet[T]) Mask() componentMask { - return s.mask -} diff --git a/game/ecs/component_sparse.go b/game/ecs/component_sparse.go deleted file mode 100644 index 0271218a..00000000 --- a/game/ecs/component_sparse.go +++ /dev/null @@ -1,64 +0,0 @@ -package ecs - -import ( - "github.com/mokiat/lacking/util/mem" -) - -// NewSparseComponentSet returns a ComponentSet implementation that allocates -// storage as needed. -// -// This implementation is more memory-friendly but this comes at a performance -// cost. It should be used only for component types that are occasionally -// attached to an entity. -func NewSparseComponentSet[T any](scene *Scene) *SparseComponentSet[T] { - result := &SparseComponentSet[T]{ - scene: scene, - mask: scene.newComponentType(), - list: mem.NewSparseList[T](1024), - mapping: make([]mem.SparseID, scene.MaxEntityCount()), - } - scene.purgeSubscriptions.Subscribe(result.Unset) - return result -} - -var _ ComponentSet[any] = (*SparseComponentSet[any])(nil) - -type SparseComponentSet[T any] struct { - scene *Scene - mask componentMask - list *mem.SparseList[T] - mapping []mem.SparseID -} - -func (s *SparseComponentSet[T]) Set(entityID EntityID, value T) { - s.scene.assignComponent(entityID, s.mask) - - if id := s.mapping[entityID.index]; s.list.Has(id) { - ref := s.list.Get(id) - *ref = value - } else { - id, ref := s.list.New() - *ref = value - s.mapping[entityID.index] = id - } -} - -func (s *SparseComponentSet[T]) Unset(entityID EntityID) { - s.scene.removeComponent(entityID, s.mask) - - id := s.mapping[entityID.index] - s.list.Delete(id) -} - -func (s *SparseComponentSet[T]) Ref(entityID EntityID) *T { - if !s.scene.hasComponent(entityID, s.mask) { - return nil - } - - id := s.mapping[entityID.index] - return s.list.Get(id) -} - -func (s *SparseComponentSet[T]) Mask() componentMask { - return s.mask -} diff --git a/game/ecs/component_tiny.go b/game/ecs/component_tiny.go deleted file mode 100644 index 8f3fd01b..00000000 --- a/game/ecs/component_tiny.go +++ /dev/null @@ -1,71 +0,0 @@ -package ecs - -import ( - "github.com/mokiat/lacking/util/mem" -) - -// NewTinyComponentSet returns a ComponentSet implementation that allocates -// very little storage for components. -// -// This implementation is the most memory-friendly but this comes at a huge -// performance cost. It should be used only for component types that are rerely -// ever attached to an entity. -func NewTinyComponentSet[T any](scene *Scene) *TinyComponentSet[T] { - result := &TinyComponentSet[T]{ - scene: scene, - mask: scene.newComponentType(), - list: mem.NewSparseList[T](16), - mapping: make(map[uint32]mem.SparseID), - } - scene.purgeSubscriptions.Subscribe(result.Unset) - return result -} - -var _ ComponentSet[any] = (*TinyComponentSet[any])(nil) - -type TinyComponentSet[T any] struct { - scene *Scene - mask componentMask - list *mem.SparseList[T] - mapping map[uint32]mem.SparseID -} - -func (s *TinyComponentSet[T]) Set(entityID EntityID, value T) { - s.Unset(entityID) - - s.scene.assignComponent(entityID, s.mask) - - if id, ok := s.mapping[entityID.index]; ok { - ref := s.list.Get(id) - *ref = value - } else { - id, ref := s.list.New() - *ref = value - s.mapping[entityID.index] = id - } -} - -func (s *TinyComponentSet[T]) Unset(entityID EntityID) { - s.scene.removeComponent(entityID, s.mask) - - if id, ok := s.mapping[entityID.index]; ok { - s.list.Delete(id) - delete(s.mapping, entityID.index) - } -} - -func (s *TinyComponentSet[T]) Ref(entityID EntityID) *T { - if !s.scene.hasComponent(entityID, s.mask) { - return nil - } - - id, ok := s.mapping[entityID.index] - if !ok { - return nil - } - return s.list.Get(id) -} - -func (s *TinyComponentSet[T]) Mask() componentMask { - return s.mask -} diff --git a/game/ecs/condition.go b/game/ecs/condition.go new file mode 100644 index 00000000..d68f7075 --- /dev/null +++ b/game/ecs/condition.go @@ -0,0 +1,65 @@ +package ecs + +import "github.com/mokiat/lacking/game/ecs/internal" + +// Condition is a predicate over an entity's component set. Conditions +// are constructed with [HasComponent], [LacksComponent], and +// [Conditions], and passed to [Scene.QueryEntities], +// [Scene.SubscribeEnter], [Scene.SubscribeExit], and +// [Scene.CheckEntity]. +type Condition struct { + positiveMask internal.TypeMask + negativeMask internal.TypeMask +} + +// HasComponent returns a condition satisfied only by entities that +// possess a component of type T. +func HasComponent[T any](compType ComponentType[T]) Condition { + return Condition{ + positiveMask: internal.TypeMaskFromType(compType.id), + negativeMask: internal.EmptyTypeMask(), + } +} + +// LacksComponent returns a condition satisfied only by entities that +// do not possess a component of type T. +func LacksComponent[T any](compType ComponentType[T]) Condition { + return Condition{ + positiveMask: internal.EmptyTypeMask(), + negativeMask: internal.TypeMaskFromType(compType.id), + } +} + +// Conditions combines multiple conditions into one that requires all +// of them to be satisfied simultaneously. +// +// Panics if the resulting condition is contradictory (e.g., both +// [HasComponent] and [LacksComponent] for the same component type). +func Conditions(conditions ...Condition) Condition { + var result Condition + for _, condition := range conditions { + result.combine(condition) + } + if result.positiveMask.Intersects(result.negativeMask) { + panic("contradictory conditions") + } + return result +} + +// Exclusive returns a derived condition that additionally requires no +// components other than the positively-required ones to be present. +// +// This is meaningful only for conditions built with [HasComponent]. +func (c Condition) Exclusive() Condition { + c.negativeMask = c.positiveMask.Inverted() + return c +} + +func (c *Condition) combine(other Condition) { + c.positiveMask.Combine(other.positiveMask) + c.negativeMask.Combine(other.negativeMask) +} + +func (c *Condition) isSatisfiedBy(mask internal.TypeMask) bool { + return mask.Contains(c.positiveMask) && !mask.Intersects(c.negativeMask) +} diff --git a/game/ecs/doc.go b/game/ecs/doc.go new file mode 100644 index 00000000..1e4e311d --- /dev/null +++ b/game/ecs/doc.go @@ -0,0 +1,30 @@ +// Package ecs provides an Entity-Component-System (ECS) framework for +// game development. It supports efficient storage and querying of +// entities and their associated components. +// +// # Core concepts +// +// A [Scope] holds the component type registry. Register Go structs as +// component types with [Type] and pass the resulting [ComponentType] +// descriptors to API functions. Scopes are shared across scenes that +// operate over the same component types. +// +// A [Scene] is the central container. It manages entity lifetime, +// stores components in archetype-grouped tables, and dispatches +// structural-change events to subscribers. +// +// Entities are referred to by [ID] values, which remain valid until the +// entity is deleted. Deletion increments an internal revision so that +// stale IDs are detected automatically. +// +// Component reads and writes are performed through [ReadOperation] and +// [EditOperation] values passed to callbacks provided to +// [Scene.ReadEntity], [Scene.EditEntity], [Scene.CreateEntity], and +// [Scene.QueryEntities]. +// +// # Systems +// +// This package does not define a System interface. System ordering, +// scheduling, and lifecycle management are the responsibility of the +// application. +package ecs diff --git a/game/ecs/engine.go b/game/ecs/engine.go deleted file mode 100644 index 2c153318..00000000 --- a/game/ecs/engine.go +++ /dev/null @@ -1,48 +0,0 @@ -package ecs - -// Option is a configuration function that can be used to customize the -// behavior of the ECS engine. -type Option func(*config) - -// WithMaxEntityCount controls the maximum number of entities that a scene -// will need to manage. -// -// Keeping this value small could reduce memory usage and increase performance. -// -// By default it is equal to 1048576 (1024x1024), which is also the maximum that -// this can be set to. -func WithMaxEntityCount(count int) Option { - return func(cfg *config) { - cfg.maxEntityCount = max(0, min(count, defaultMaxEntityCount)) - } -} - -type config struct { - maxEntityCount int -} - -// NewEngine creates a new ECS engine. -func NewEngine(opts ...Option) *Engine { - cfg := config{ - maxEntityCount: defaultMaxEntityCount, - } - for _, opt := range opts { - opt(&cfg) - } - return &Engine{ - maxEntityCount: cfg.maxEntityCount, - } -} - -// Engine is the entrypoint to working with an -// Entity-Component System framework. -type Engine struct { - maxEntityCount int -} - -// CreateScene creates a new Scene instance. -// Entities within a scene are isolated from -// entities in other scenes. -func (e *Engine) CreateScene() *Scene { - return newScene(e.maxEntityCount) -} diff --git a/game/ecs/entity.go b/game/ecs/entity.go deleted file mode 100644 index 7760a098..00000000 --- a/game/ecs/entity.go +++ /dev/null @@ -1,72 +0,0 @@ -package ecs - -import "iter" - -// NilEntityID represents an invalid entity handle. -var NilEntityID = EntityID{} - -// EntityID represents a handle to an ECS entity. The handle may be invalid -// if the entity has since been deleted. -type EntityID struct { - index uint32 - revision uint32 -} - -type entityHandle struct { - components componentMask - revision uint32 - isPendingDeletion bool -} - -func newBitmask() *bitmask { - return new(bitmask) -} - -type bitmask struct { - values [16384]uint64 -} - -func (m *bitmask) Clear() { - for i := range m.values { - m.values[i] = 0x00 - } -} - -func (m *bitmask) Get(index uint32) bool { - bucket := index / 64 - offset := index % 64 - query := uint64(1 << offset) - return (m.values[bucket] & query) != 0 -} - -func (m *bitmask) Set(index uint32, active bool) { - bucket := index / 64 - offset := index % 64 - query := uint64(1 << offset) - if active { - m.values[bucket] |= query - } else { - m.values[bucket] &= ^query - } -} - -func (m *bitmask) ActiveIter() iter.Seq[uint32] { - return func(yield func(uint32) bool) { - var index uint32 - for _, group := range m.values { - if group == 0 { // skip whole group - index += 64 - continue - } - for offset := range 64 { - query := uint64(1 << offset) - if (group & query) != 0 { - if !yield(index) { - return - } - } - index++ - } - } - } -} diff --git a/game/ecs/id.go b/game/ecs/id.go new file mode 100644 index 00000000..ac0bb739 --- /dev/null +++ b/game/ecs/id.go @@ -0,0 +1,23 @@ +package ecs + +import "github.com/mokiat/lacking/game/ecs/internal" + +// NilID is the zero value of [ID] and represents an invalid entity handle. +var NilID = ID{} + +// ID is a versioned handle to an entity. It remains valid until the +// entity is deleted. After deletion, the ID compares unequal to any +// new entity that reuses the same internal slot. +// +// ID is small and should be stored and passed by value. +type ID struct { + index uint32 + revision uint32 +} + +func fromInternalID(internalID internal.ID) ID { + return ID{ + index: internalID.Index, + revision: internalID.Revision, + } +} diff --git a/game/ecs/internal/archetype.go b/game/ecs/internal/archetype.go new file mode 100644 index 00000000..ac2e46f1 --- /dev/null +++ b/game/ecs/internal/archetype.go @@ -0,0 +1,131 @@ +package internal + +// NewArchetype creates a new Archetype. +func NewArchetype(registry *Registry) *Archetype { + return &Archetype{ + registry: registry, + } +} + +// Archetype represents a unique combination of component types. It is used to +// group entities that have the same set of component types together for +// efficient storage and querying. +type Archetype struct { + registry *Registry + mask TypeMask + size uint32 + + idColumn *Column[ID] + componentColumnIDs []ColumnID + componentLookup TypeLookup +} + +// Revive initializes the archetype with the specified type mask. It sets up the +// necessary columns for the component types included in the mask. +func (a *Archetype) Revive(mask TypeMask) { + a.mask = mask + a.size = 0 + + mask.EachType(func(id TypeID) { + storage := a.registry.Storage(id) + a.componentLookup[id] = uint8(len(a.componentColumnIDs)) + a.componentColumnIDs = append(a.componentColumnIDs, storage.AllocateColumn()) + }) + + entityIDStorage := a.registry.IDStorage() + a.idColumn = entityIDStorage.NewColumn() +} + +// Destroy cleans up the archetype and releases any resources it holds. +// It should be called when the archetype is no longer needed, such as when it +// is being returned to the archetype pool. +func (a *Archetype) Destroy() { + a.mask.EachType(func(id TypeID) { + storage := a.registry.Storage(id) + columnID := a.componentColumnIDs[a.componentLookup[id]] + storage.ReleaseColumn(columnID) + }) + a.componentLookup = TypeLookup{} + a.componentColumnIDs = a.componentColumnIDs[:0] + + a.idColumn.Release() + a.idColumn = nil + + a.mask = EmptyTypeMask() + a.size = 0 +} + +// TypeMask returns the type mask associated with the archetype. +func (a *Archetype) TypeMask() TypeMask { + return a.mask +} + +// Size returns the number of entities currently stored in the archetype. +func (a *Archetype) Size() uint32 { + return a.size +} + +// IsEmpty returns whether the archetype has no entities. +func (a *Archetype) IsEmpty() bool { + return a.size == 0 +} + +// IDColumn returns the column that stores the entity IDs for the archetype. +func (a *Archetype) IDColumn() *Column[ID] { + return a.idColumn +} + +// ComponentColumnID returns the ID of the column associated with the specified +// component type ID. +func (a *Archetype) ComponentColumnID(id TypeID) ColumnID { + return a.componentColumnIDs[a.componentLookup[id]] +} + +// ComponentColumnIDs returns the column IDs for the component types in the +// archetype, along with a lookup that maps component type IDs to their +// corresponding indices in the returned slice. +func (a *Archetype) ComponentColumnIDs() ([]ColumnID, TypeLookup) { + return a.componentColumnIDs, a.componentLookup +} + +// LastRow returns the index of the last row in the table represented by the +// archetype. +// +// Calling this for an empty archetype will return an invalid row index. +func (a *Archetype) LastRow() Row { + return Row(a.size - 1) +} + +// Grow appends a new row to the table represented by the archetype. +func (a *Archetype) Grow() Row { + a.size++ + a.mask.EachType(func(id TypeID) { + storage := a.registry.Storage(id) + columnID := a.componentColumnIDs[a.componentLookup[id]] + storage.GrowColumn(columnID) + }) + a.idColumn.Grow() + return Row(a.size - 1) +} + +// Shrink removes the last row from the table represented by the archetype. +func (a *Archetype) Shrink() { + a.size-- + a.mask.EachType(func(id TypeID) { + storage := a.registry.Storage(id) + columnID := a.componentColumnIDs[a.componentLookup[id]] + storage.ShrinkColumn(columnID) + }) + a.idColumn.Shrink() +} + +// CopyRow copies the component values from the source row to the destination +// row in the table represented by the archetype. +func (a *Archetype) CopyRow(dst, src Row) { + a.mask.EachType(func(id TypeID) { + storage := a.registry.Storage(id) + columnID := a.componentColumnIDs[a.componentLookup[id]] + storage.CopyCell(columnID, dst, columnID, src) + }) + a.idColumn.Copy(dst, src) +} diff --git a/game/ecs/internal/buffer.go b/game/ecs/internal/buffer.go new file mode 100644 index 00000000..e788b2a2 --- /dev/null +++ b/game/ecs/internal/buffer.go @@ -0,0 +1,58 @@ +package internal + +import "unsafe" + +func NewBuffer(initialCapacity int) *Buffer { + return &Buffer{ + data: make([]byte, initialCapacity), + } +} + +type Buffer struct { + data []byte + writeOffset uintptr + readOffset uintptr +} + +func (b *Buffer) HasMoreData() bool { + return b.writeOffset > b.readOffset +} + +func (b *Buffer) Reset() { + b.writeOffset = 0 + b.readOffset = 0 +} + +func (b *Buffer) ensure(itemSize int) { + required := int(b.writeOffset) + itemSize + available := len(b.data) + if required > available { + b.data = append(b.data, make([]byte, required-available)...) + b.data = b.data[:cap(b.data)] + } +} + +func WriteToBuffer[T any](buffer *Buffer, command T) uint32 { + size := unsafe.Sizeof(command) + buffer.ensure(int(size)) + + target := (*T)(unsafe.Add(unsafe.Pointer(&buffer.data[0]), buffer.writeOffset)) + *target = command + + result := uint32(buffer.writeOffset) + buffer.writeOffset += size + return result +} + +func ReadFromBuffer[T any](buffer *Buffer) T { + target := (*T)(unsafe.Add(unsafe.Pointer(&buffer.data[0]), buffer.readOffset)) + command := *target + buffer.readOffset += unsafe.Sizeof(command) + return command +} + +func ReadFromBufferOffset[T any](buffer *Buffer, offset uint32) T { + target := (*T)(unsafe.Add(unsafe.Pointer(&buffer.data[0]), offset)) + command := *target + return command +} diff --git a/game/ecs/internal/chunk.go b/game/ecs/internal/chunk.go new file mode 100644 index 00000000..068b4f64 --- /dev/null +++ b/game/ecs/internal/chunk.go @@ -0,0 +1,5 @@ +package internal + +const chunkSize = 128 + +type DataChunk[T any] *[chunkSize]T diff --git a/game/ecs/internal/column.go b/game/ecs/internal/column.go new file mode 100644 index 00000000..b821104a --- /dev/null +++ b/game/ecs/internal/column.go @@ -0,0 +1,93 @@ +package internal + +// ColumnID represents a unique identifier for a column in the component +// storage. +type ColumnID uint32 + +// NewColumn creates a new column for storing component values of type T using +// the provided storage. +func NewColumn[T any](storage *Storage[T], id ColumnID) *Column[T] { + return &Column[T]{ + storage: storage, + id: id, + chunks: nil, + } +} + +// Column is a column in the component storage for a specific component type T. +// It manages the storage of component values for multiple entities, using +// chunks to efficiently allocate memory. +// +// NOTE: A huge benefit of using chunks is that they are immutable during +// growth, which means that references to existing chunks are not invalidated, +// unlike using a single slice and appending to it. +type Column[T any] struct { + storage *Storage[T] + id ColumnID + chunks []DataChunk[T] + size uint32 +} + +// ID returns the unique identifier of the column in the component storage. +func (c *Column[T]) ID() ColumnID { + return c.id +} + +// Grow appends an additional row to the column. A zero value is placed. +func (c *Column[T]) Grow() { + if c.size%chunkSize == 0 { + c.chunks = append(c.chunks, c.storage.allocateChunk()) + } + c.size++ +} + +// Shrink removes the last row of the column. The value is lost. +func (c *Column[T]) Shrink() { + c.size-- + if c.size%chunkSize == 0 { + lastChunkIndex := len(c.chunks) - 1 + c.storage.releaseChunk(c.chunks[lastChunkIndex]) + c.chunks = c.chunks[:lastChunkIndex] + } +} + +// Copy copies the component values from the source row to the destination +// row. +func (c *Column[T]) Copy(dst, src Row) { + if dst != src { + c.SetValue(dst, c.Value(src)) + } +} + +// Value returns the value at the specified row in the column. +func (c *Column[T]) Value(row Row) T { + chunkIndex := row / chunkSize + cellIndex := row % chunkSize + return c.chunks[chunkIndex][cellIndex] +} + +// SetValue sets the value at the specified row in the column. +func (c *Column[T]) SetValue(row Row, value T) { + chunkIndex := row / chunkSize + cellIndex := row % chunkSize + c.chunks[chunkIndex][cellIndex] = value +} + +// RefValue returns a reference to the value at the specified row in the column. +func (c *Column[T]) RefValue(row Row) *T { + chunkIndex := row / chunkSize + cellIndex := row % chunkSize + return &c.chunks[chunkIndex][cellIndex] +} + +// Release releases any resources associated with the column, such as +// allocated chunks, and resets the column to an empty state. +func (c *Column[T]) Release() { + for _, chunk := range c.chunks { + c.storage.releaseChunk(chunk) + } + c.chunks = c.chunks[:0] + c.size = 0 + + c.storage.releaseColumnID(c.id) +} diff --git a/game/ecs/internal/command.go b/game/ecs/internal/command.go new file mode 100644 index 00000000..51fa3443 --- /dev/null +++ b/game/ecs/internal/command.go @@ -0,0 +1,44 @@ +package internal + +type CommandHeader struct { + CommandType CommandType +} + +const ( + CommandTypeNone CommandType = iota + CommandTypeEndOfSequence + CommandTypeCreateEntity + CommandTypeEditEntity + CommandTypeDeleteEntity + CommandTypeAddComponent + CommandTypeRemoveComponent + CommandTypeReplaceComponent +) + +type CommandType uint32 + +type CreateEntityCommand struct { + EntityID ID + StageRow Row +} + +type EditEntityCommand struct { + EntityID ID + StageRow Row +} + +type DeleteEntityCommand struct { + EntityID ID +} + +type AddComponentCommand struct { + TypeID TypeID +} + +type RemoveComponentCommand struct { + TypeID TypeID +} + +type ReplaceComponentCommand struct { + TypeID TypeID +} diff --git a/game/ecs/internal/component_type.go b/game/ecs/internal/component_type.go new file mode 100644 index 00000000..33d0570f --- /dev/null +++ b/game/ecs/internal/component_type.go @@ -0,0 +1,127 @@ +package internal + +import ( + "math/bits" +) + +// MaxComponentTypes is the maximum number of component types that can be +// registered in the ECS. +const MaxComponentTypes = 1 << 8 + +// TypeID is a unique identifier for a component type. +type TypeID uint8 + +// EmptyTypeMask returns an empty TypeMask with no component types. +func EmptyTypeMask() TypeMask { + return TypeMask{} +} + +// TypeMaskFromType returns a TypeMask containing only the component type with +// the given ID. +func TypeMaskFromType(id TypeID) TypeMask { + var mask TypeMask + mask.AddType(id) + return mask +} + +// TypeMaskFromTypes returns a TypeMask containing the component types with the +// given IDs. +func TypeMaskFromTypes(ids ...TypeID) TypeMask { + var mask TypeMask + for _, id := range ids { + mask.AddType(id) + } + return mask +} + +// TypeMask is a bitmask representing a set of component types. Each bit in the +// mask corresponds to a component type. +type TypeMask [4]uint64 + +// Clear removes all component types from the TypeMask, resulting in an empty +// TypeMask. +func (m *TypeMask) Clear() { + for i := range m { + m[i] = 0 + } +} + +// AddType adds the component type with the given ID to the TypeMask. +func (m *TypeMask) AddType(id TypeID) { + index := int(id >> 6) + mask := uint64(1 << (id & 0x3F)) + m[index] |= mask +} + +// RemoveType removes the component type with the given ID from the TypeMask. +func (m *TypeMask) RemoveType(id TypeID) { + index := int(id >> 6) + mask := uint64(1 << (id & 0x3F)) + m[index] &^= mask +} + +// HasType checks if the TypeMask contains the component type with the given ID. +func (m *TypeMask) HasType(id TypeID) bool { + index := int(id >> 6) + mask := uint64(1 << (id & 0x3F)) + return (m[index] & mask) != 0 +} + +// Inverted returns a new TypeMask that contains all component types not present +// in the original TypeMask and does not contain any component types present in +// the original TypeMask. +func (m *TypeMask) Inverted() TypeMask { + var result TypeMask + for i := range m { + result[i] = ^m[i] + } + return result +} + +// Combine adds all component types from the other TypeMask to the current +// TypeMask. +func (m *TypeMask) Combine(other TypeMask) { + for i := range m { + m[i] |= other[i] + } +} + +// Intersects checks if the TypeMask shares any component types with the other +// TypeMask. +func (m *TypeMask) Intersects(other TypeMask) bool { + for i := range m { + if (m[i] & other[i]) != 0 { + return true + } + } + return false +} + +// Contains checks if the TypeMask contains all component types in the other +// TypeMask. +func (m *TypeMask) Contains(other TypeMask) bool { + for i := range m { + if (m[i] & other[i]) != other[i] { + return false + } + } + return true +} + +// EachType iterates over all component types in the TypeMask and calls the +// provided function with the ID of each component type. +func (m *TypeMask) EachType(f func(TypeID)) { + for i, mask := range m { + for mask != 0 { + bitIndex := bits.TrailingZeros64(mask) + id := TypeID(i*64 + bitIndex) + f(id) + mask &= (mask - 1) + } + } +} + +// TypeLookup is a mapping from component type IDs to their corresponding +// indices in an external array (e.g. the components array in a component +// archetype). +type TypeLookup [MaxComponentTypes]uint8 diff --git a/game/ecs/internal/component_type_test.go b/game/ecs/internal/component_type_test.go new file mode 100644 index 00000000..e8b6a621 --- /dev/null +++ b/game/ecs/internal/component_type_test.go @@ -0,0 +1,146 @@ +package internal_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mokiat/gog" + "github.com/mokiat/lacking/game/ecs/internal" +) + +var _ = Describe("TypeMask", func() { + var mask internal.TypeMask + + BeforeEach(func() { + mask = internal.EmptyTypeMask() + }) + + It("has no types when empty", func() { + for index := range internal.MaxComponentTypes { + id := internal.TypeID(index) + Expect(mask.HasType(id)).To(BeFalse()) + } + }) + + When("types are added", func() { + BeforeEach(func() { + mask.AddType(internal.TypeID(0)) + mask.AddType(internal.TypeID(64)) + mask.AddType(internal.TypeID(255)) + }) + + It("contains the added types", func() { + Expect(mask.HasType(internal.TypeID(0))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(64))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(255))).To(BeTrue()) + }) + + It("does not contain other types", func() { + Expect(mask.HasType(internal.TypeID(1))).To(BeFalse()) + Expect(mask.HasType(internal.TypeID(36))).To(BeFalse()) + Expect(mask.HasType(internal.TypeID(62))).To(BeFalse()) + Expect(mask.HasType(internal.TypeID(63))).To(BeFalse()) + Expect(mask.HasType(internal.TypeID(254))).To(BeFalse()) + }) + + When("cleared", func() { + BeforeEach(func() { + mask.Clear() + }) + + It("no longer contains any types", func() { + for index := range internal.MaxComponentTypes { + id := internal.TypeID(index) + Expect(mask.HasType(id)).To(BeFalse()) + } + }) + }) + + When("types are removed", func() { + BeforeEach(func() { + mask.RemoveType(internal.TypeID(64)) + }) + + It("no longer contains the removed type", func() { + Expect(mask.HasType(internal.TypeID(64))).To(BeFalse()) + }) + + It("still contains the other types", func() { + Expect(mask.HasType(internal.TypeID(0))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(255))).To(BeTrue()) + }) + }) + + When("inverted", func() { + var inverted internal.TypeMask + + BeforeEach(func() { + inverted = mask.Inverted() + }) + + It("does not contain the original types", func() { + Expect(inverted.HasType(internal.TypeID(0))).To(BeFalse()) + Expect(inverted.HasType(internal.TypeID(64))).To(BeFalse()) + Expect(inverted.HasType(internal.TypeID(255))).To(BeFalse()) + }) + + It("contains all other types", func() { + for index := range internal.MaxComponentTypes { + id := internal.TypeID(index) + if gog.IsOneOf(id, 0, 64, 255) { + continue + } + Expect(inverted.HasType(id)).To(BeTrue()) + } + }) + }) + + When("combining masks", func() { + BeforeEach(func() { + other := internal.TypeMaskFromTypes(64, 128) + mask.Combine(other) + }) + + It("contains types from both masks", func() { + Expect(mask.HasType(internal.TypeID(0))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(64))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(128))).To(BeTrue()) + Expect(mask.HasType(internal.TypeID(255))).To(BeTrue()) + }) + }) + + It("is possible to check for intersection with another mask", func() { + other := internal.TypeMaskFromTypes(64, 255) + Expect(mask.Intersects(other)).To(BeTrue()) + + other = internal.TypeMaskFromTypes(255) + Expect(mask.Intersects(other)).To(BeTrue()) + + other = internal.TypeMaskFromTypes(1) + Expect(mask.Intersects(other)).To(BeFalse()) + }) + + It("is possible to check if it contains another mask", func() { + other := internal.TypeMaskFromTypes(0, 64) + Expect(mask.Contains(other)).To(BeTrue()) + + other = internal.TypeMaskFromTypes(0, 255) + Expect(mask.Contains(other)).To(BeTrue()) + + other = internal.TypeMaskFromTypes(0, 64, 128, 255) + Expect(mask.Contains(other)).To(BeFalse()) + }) + + It("can iterate over its types", func() { + var types []internal.TypeID + mask.EachType(func(id internal.TypeID) { + types = append(types, id) + }) + Expect(types).To(ConsistOf( + internal.TypeID(0), + internal.TypeID(64), + internal.TypeID(255), + )) + }) + }) +}) diff --git a/game/ecs/internal/entity.go b/game/ecs/internal/entity.go new file mode 100644 index 00000000..b38ee140 --- /dev/null +++ b/game/ecs/internal/entity.go @@ -0,0 +1,65 @@ +package internal + +type Entity struct { + index uint32 + revision uint32 + archetype *Archetype + archetypeRow Row +} + +// ID returns the ID of the entity. +func (e *Entity) ID() ID { + return NewID(e.index, e.revision) +} + +// Revive revives the entity with the specified archetype and archetype row. +// This method is used to reuse entity slots in the scene when entities are +// deleted and recreated. +func (e *Entity) Revive(index uint32) { + e.index = index + e.revision++ + e.archetype = nil // in limbo + e.archetypeRow = 0 +} + +// Destroy destroys the entity and returns the archetype and archetype row that +// were associated with the entity before it was destroyed. +func (e *Entity) Destroy() (*Archetype, Row) { + archetype := e.archetype + archetypeRow := e.archetypeRow + + e.archetype = nil + e.archetypeRow = 0 + e.revision++ + + return archetype, archetypeRow +} + +// Revision returns the current revision of the entity, which is incremented +// each time the entity is deleted and recreated. +func (e *Entity) Revision() uint32 { + return e.revision +} + +// HasRevision returns whether the entity's current revision matches the +// specified revision. +func (e *Entity) HasRevision(revision uint32) bool { + return e.revision == revision +} + +// Archetype returns the archetype associated with the entity. +func (e *Entity) Archetype() *Archetype { + return e.archetype +} + +// ArchetypeRow returns the archetype row associated with the entity. +func (e *Entity) ArchetypeRow() Row { + return e.archetypeRow +} + +// Assign changes the archetype and archetype row associated with the entity. +func (e *Entity) Assign(archetype *Archetype, row Row) { + e.archetype = archetype + e.archetypeRow = row + archetype.IDColumn().SetValue(row, NewID(e.index, e.revision)) +} diff --git a/game/ecs/internal/id.go b/game/ecs/internal/id.go new file mode 100644 index 00000000..b1c2dde4 --- /dev/null +++ b/game/ecs/internal/id.go @@ -0,0 +1,19 @@ +package internal + +var NilID = ID{} + +func NewID(index uint32, revision uint32) ID { + return ID{ + Index: index, + Revision: revision, + } +} + +// ID represents a handle to an ECS entity. The handle may be invalid +// if the entity has since been deleted. +// +// Store this type by value, as it is small and copyable. +type ID struct { + Index uint32 + Revision uint32 +} diff --git a/game/ecs/internal/registry.go b/game/ecs/internal/registry.go new file mode 100644 index 00000000..b647ef3d --- /dev/null +++ b/game/ecs/internal/registry.go @@ -0,0 +1,32 @@ +package internal + +// NewRegistry creates and initializes a new registry. +func NewRegistry() *Registry { + return &Registry{ + idStorage: NewStorage[ID](), + } +} + +// Registry represents a registry of component storages. It provides methods for +// registering and retrieving component storages based on component type +// identifiers. +type Registry struct { + idStorage *Storage[ID] + storages [MaxComponentTypes]AnyStorage +} + +// IDStorage returns the storage used for storing ID values. +func (r *Registry) IDStorage() *Storage[ID] { + return r.idStorage +} + +// Storage returns the component storage associated with the specified component +// type. +func (r *Registry) Storage(id TypeID) AnyStorage { + return r.storages[id] +} + +// SetStorage registers the component storage for the specified component type. +func (r *Registry) SetStorage(id TypeID, storage AnyStorage) { + r.storages[id] = storage +} diff --git a/game/ecs/internal/row.go b/game/ecs/internal/row.go new file mode 100644 index 00000000..e85a4c81 --- /dev/null +++ b/game/ecs/internal/row.go @@ -0,0 +1,4 @@ +package internal + +// Row represents a row index in a table. +type Row uint32 diff --git a/game/ecs/internal/stager.go b/game/ecs/internal/stager.go new file mode 100644 index 00000000..d1797b0f --- /dev/null +++ b/game/ecs/internal/stager.go @@ -0,0 +1,67 @@ +package internal + +func NewStager(registry *Registry) *Stager { + var ( + componentColumnIDs []ColumnID + componentLookup TypeLookup + mask TypeMask + ) + + for typeID, storage := range registry.storages { + if storage == nil { + continue + } + + columnID := storage.AllocateColumn() + storage.GrowColumn(columnID) + + componentLookup[typeID] = uint8(len(componentColumnIDs)) + componentColumnIDs = append(componentColumnIDs, columnID) + mask.AddType(TypeID(typeID)) + } + + return &Stager{ + registry: registry, + componentColumnIDs: componentColumnIDs, + componentLookup: componentLookup, + mask: mask, + capacity: 1, + size: 0, + } +} + +type Stager struct { + registry *Registry + componentColumnIDs []ColumnID + componentLookup TypeLookup + mask TypeMask + capacity uint32 + size uint32 +} + +func (s *Stager) Clear() { + s.size = 0 +} + +func (s *Stager) Grow() Row { + s.size++ + if s.size > s.capacity { + s.capacity++ + s.mask.EachType(func(id TypeID) { + columnID := s.componentColumnIDs[s.componentLookup[id]] + s.registry.Storage(id).GrowColumn(columnID) + }) + } + return Row(s.size - 1) +} + +func (s *Stager) ComponentColumnID(id TypeID) ColumnID { + return s.componentColumnIDs[id] +} + +func (s *Stager) Destroy() { + s.mask.EachType(func(id TypeID) { + columnID := s.componentColumnIDs[s.componentLookup[id]] + s.registry.Storage(id).ReleaseColumn(columnID) + }) +} diff --git a/game/ecs/internal/storage.go b/game/ecs/internal/storage.go new file mode 100644 index 00000000..b6549ea6 --- /dev/null +++ b/game/ecs/internal/storage.go @@ -0,0 +1,123 @@ +package internal + +import ( + "github.com/mokiat/gog/ds" +) + +// AnyStorage represents a base interface for storage, which is responsible for +// allocating and managing columns for component storage. +type AnyStorage interface { + + // AllocateColumn allocates a new column for storing component values and + // returns the ID of the allocated column. + AllocateColumn() ColumnID + + // ReleaseColumn reclaims the column with the specified ID, making it + // available for future allocations. + ReleaseColumn(id ColumnID) + + // CopyCell copies the component value from the source column and row to the + // destination column and row. + CopyCell(dstColumnID ColumnID, dstRow Row, srcColumnID ColumnID, srcRow Row) + + // GrowColumn grows the column with the specified ID by adding an additional + // row to it. + GrowColumn(id ColumnID) + + // ShrinkColumn shrinks the column with the specified ID by removing the last + // row from it. + ShrinkColumn(id ColumnID) +} + +// NewStorage creates a new storage for columns of type T. +func NewStorage[T any]() *Storage[T] { + return &Storage[T]{ + chunks: ds.EmptyStack[DataChunk[T]](), + + freeColumns: ds.EmptyStack[ColumnID](), + } +} + +// Storage acts as a memory manager for columns by allocating and releasing +// columns and pooling them. +type Storage[T any] struct { + chunks *ds.Stack[DataChunk[T]] + + freeColumns *ds.Stack[ColumnID] + columns []*Column[T] +} + +var _ AnyStorage = (*Storage[struct{}])(nil) + +// AllocateColumn allocates a new column for storing component values and +// returns the ID of the allocated column. +func (s *Storage[T]) AllocateColumn() ColumnID { + return s.allocateColumnID() +} + +// ReleaseColumn reclaims the column with the specified ID, making it +// available for future allocations. +func (s *Storage[T]) ReleaseColumn(id ColumnID) { + column := s.Column(id) + column.Release() +} + +// NewColumn allocates a new column for storing component values of type T and +// returns the allocated column. +func (s *Storage[T]) NewColumn() *Column[T] { + id := s.allocateColumnID() + return s.columns[id] +} + +// Column returns the column with the specified ID from the storage. +func (s *Storage[T]) Column(id ColumnID) *Column[T] { + return s.columns[id] +} + +// CopyCell copies the component value from the source column and row to the +// destination column and row. +func (s *Storage[T]) CopyCell(dstColumnID ColumnID, dstRow Row, srcColumnID ColumnID, srcRow Row) { + dstColumn := s.Column(dstColumnID) + srcColumn := s.Column(srcColumnID) + dstColumn.SetValue(dstRow, srcColumn.Value(srcRow)) +} + +// GrowColumn grows the column with the specified ID by adding an additional +// row to it. +func (s *Storage[T]) GrowColumn(id ColumnID) { + column := s.columns[id] + column.Grow() +} + +// ShrinkColumn shrinks the column with the specified ID by removing the last +// row from it. +func (s *Storage[T]) ShrinkColumn(id ColumnID) { + column := s.columns[id] + column.Shrink() +} + +func (s *Storage[T]) allocateColumnID() ColumnID { + if s.freeColumns.IsEmpty() { + id := ColumnID(len(s.columns)) + column := NewColumn(s, ColumnID(id)) + s.columns = append(s.columns, column) + return id + } + return s.freeColumns.Pop() +} + +func (s *Storage[T]) releaseColumnID(columnID ColumnID) { + s.freeColumns.Push(columnID) +} + +func (s *Storage[T]) allocateChunk() DataChunk[T] { + if s.chunks.IsEmpty() { + return new([chunkSize]T) + } else { + return s.chunks.Pop() + } +} + +func (s *Storage[T]) releaseChunk(chunk DataChunk[T]) { + s.chunks.Push(chunk) +} diff --git a/game/ecs/internal/suite_test.go b/game/ecs/internal/suite_test.go new file mode 100644 index 00000000..2dcdfa99 --- /dev/null +++ b/game/ecs/internal/suite_test.go @@ -0,0 +1,13 @@ +package internal_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestECSInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ECS Internal Suite") +} diff --git a/game/ecs/operation.go b/game/ecs/operation.go new file mode 100644 index 00000000..8e7d9be2 --- /dev/null +++ b/game/ecs/operation.go @@ -0,0 +1,107 @@ +package ecs + +import "github.com/mokiat/lacking/game/ecs/internal" + +// EditOperation is the write handle passed to [Scene.EditEntity] and +// [Scene.CreateEntity] callbacks. Use [AddComponent], [RemoveComponent], +// and [ReplaceComponent] to stage component changes. +// +// Do not create instances directly or retain the pointer beyond the +// callback scope. +type EditOperation struct { + stager *internal.Stager + commandBuffer *internal.Buffer + stageRow internal.Row +} + +// EditOperationFunc is the callback signature accepted by +// [Scene.EditEntity] and [Scene.CreateEntity]. +type EditOperationFunc func(op *EditOperation) + +// AddComponent stages the addition of a component of type T with the +// given value to the entity being edited. +// +// Panics at commit time if the entity already has a component of type T +// (as determined by the virtual state after prior operations in the same +// edit). +func AddComponent[T any](op *EditOperation, compType ComponentType[T], value T) { + columnID := op.stager.ComponentColumnID(compType.id) + column := compType.storage.Column(columnID) + column.SetValue(op.stageRow, value) + + internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeAddComponent, + }) + internal.WriteToBuffer(op.commandBuffer, internal.AddComponentCommand{ + TypeID: compType.id, + }) +} + +// RemoveComponent stages the removal of the component of type T from +// the entity being edited. +// +// Panics at commit time if the entity does not have a component of +// type T (as determined by the virtual state after prior operations in +// the same edit). +func RemoveComponent[T any](op *EditOperation, compType ComponentType[T]) { + internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeRemoveComponent, + }) + internal.WriteToBuffer(op.commandBuffer, internal.RemoveComponentCommand{ + TypeID: compType.id, + }) +} + +// ReplaceComponent stages a value update for the component of type T on +// the entity being edited. Unlike [RemoveComponent] followed by +// [AddComponent], this does not change the entity's archetype. +// +// Panics at commit time if the entity does not have a component of +// type T (as determined by the virtual state after prior operations in +// the same edit). +func ReplaceComponent[T any](op *EditOperation, compType ComponentType[T], value T) { + columnID := op.stager.ComponentColumnID(compType.id) + column := compType.storage.Column(columnID) + column.SetValue(op.stageRow, value) + + internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeReplaceComponent, + }) + internal.WriteToBuffer(op.commandBuffer, internal.ReplaceComponentCommand{ + TypeID: compType.id, + }) +} + +// ReadOperation is the read handle passed to [Scene.ReadEntity] and +// [Scene.QueryEntities] callbacks. Use [GetComponent] or +// [InjectComponent] to retrieve component values. +// +// Do not create instances directly or retain the pointer beyond the +// callback scope. +type ReadOperation struct { + mask internal.TypeMask + row internal.Row + + componentLookup internal.TypeLookup + componentColumnIDs []internal.ColumnID +} + +// GetComponent returns a pointer to the component of type T for the +// entity currently being read, or nil if the entity does not have the +// component. +func GetComponent[T any](op *ReadOperation, compType ComponentType[T]) *T { + if !op.mask.HasType(compType.id) { + return nil + } + + columnID := op.componentColumnIDs[op.componentLookup[compType.id]] + column := compType.storage.Column(columnID) + return column.RefValue(op.row) +} + +// InjectComponent sets *target to the component of type T for the +// entity currently being read, or nil if the entity does not have the +// component. It is a convenience wrapper around [GetComponent]. +func InjectComponent[T any](op *ReadOperation, compType ComponentType[T], target **T) { + *target = GetComponent(op, compType) +} diff --git a/game/ecs/query.go b/game/ecs/query.go deleted file mode 100644 index dba290da..00000000 --- a/game/ecs/query.go +++ /dev/null @@ -1,120 +0,0 @@ -package ecs - -import ( - "iter" - - "github.com/mokiat/gog/opt" -) - -// HasComponent returns a query condition that requires an entity to have -// a certain component. -func HasComponent[T any](set ComponentSet[T]) Condition { - return Condition{ - positiveMask: set.Mask(), - negativeMask: componentMask(0), - isPendingDeletion: opt.Unspecified[bool](), - } -} - -// LacksComponent returns a query condition that requires an entity to not -// have a certain component. -func LacksComponent[T any](set ComponentSet[T]) Condition { - return Condition{ - positiveMask: componentMask(0), - negativeMask: set.Mask(), - isPendingDeletion: opt.Unspecified[bool](), - } -} - -// IsHealthy returns a query condition that requires an entity to not be -// pending deletion. -func IsHealthy() Condition { - return Condition{ - positiveMask: componentMask(0), - negativeMask: componentMask(0), - isPendingDeletion: opt.V(false), - } -} - -// IsPendingDeletion returns a query condition that requires an entity to -// have been marked for deletion. -func IsPendingDeletion() Condition { - return Condition{ - positiveMask: componentMask(0), - negativeMask: componentMask(0), - isPendingDeletion: opt.V(true), - } -} - -// Condition represents a query condition that needs to be satisfied -// for an entity to be returned. -type Condition struct { - positiveMask componentMask - negativeMask componentMask - isPendingDeletion opt.T[bool] -} - -func (c *Condition) apply(other Condition) { - c.positiveMask |= other.positiveMask - c.negativeMask |= other.negativeMask - if other.isPendingDeletion.Specified { - c.isPendingDeletion = other.isPendingDeletion - } -} - -func (c *Condition) isSatisfied(handle *entityHandle) bool { - if (handle.components & c.positiveMask) != c.positiveMask { - return false - } - if (handle.components & c.negativeMask) != 0 { - return false - } - if c.isPendingDeletion.Specified && (c.isPendingDeletion.Value != handle.isPendingDeletion) { - return false - } - return true -} - -// Result represents the outcome of a query operation. -// -// Make sure to call Release once you are done with it so that -// it can be reused in future searches. -type Result struct { - scene *Scene - entityMask *bitmask -} - -// Each invokes the callback function for each entity in this result set. -// -// While less elegant than Iter, it does not incur unnecessary allocations. -func (r *Result) Each(cb func(EntityID)) { - for entityIndex := range r.entityMask.ActiveIter() { - handle := r.scene.handles[entityIndex] - cb(EntityID{ - index: entityIndex, - revision: handle.revision, - }) - } -} - -// Iter returns an iterator over the entities in this result set. -func (r *Result) Iter() iter.Seq[EntityID] { - return func(yield func(EntityID) bool) { - for entityIndex := range r.entityMask.ActiveIter() { - handle := r.scene.handles[entityIndex] - entityID := EntityID{ - index: entityIndex, - revision: handle.revision, - } - if !yield(entityID) { - return - } - } - } -} - -// Release frees resources allocated for this result. -func (r *Result) Release() { - r.scene.results.Release(r) - r.scene = nil -} diff --git a/game/ecs/scene.go b/game/ecs/scene.go index e83b78b1..8e37b83d 100644 --- a/game/ecs/scene.go +++ b/game/ecs/scene.go @@ -1,175 +1,657 @@ package ecs import ( + "fmt" + "iter" + "github.com/mokiat/gog/ds" - "github.com/mokiat/gog/seq" - "github.com/mokiat/lacking/util/mem" + "github.com/mokiat/gog/opt" + "github.com/mokiat/lacking/game/ecs/internal" "github.com/mokiat/lacking/util/observer" ) -const defaultMaxEntityCount = 1024 * 1024 +// NewScene creates a [Scene] backed by the component types registered +// in scope. A scene owns its own archetype storage and entity table and +// does not share data with other scenes. +func NewScene(scope *Scope) *Scene { + scope.markInUse() + + const initialReadOperations = 2 + readOperations := ds.PreallocatedStack[*ReadOperation](initialReadOperations) + for range initialReadOperations { + readOperations.Push(new(ReadOperation)) + } -func newScene(maxEntityCount int) *Scene { - freeHandleIndices := ds.NewStack[uint32](maxEntityCount) - for i := range seq.Range(maxEntityCount-1, 0) { - freeHandleIndices.Push(uint32(i)) + const initialEditOperations = 1 + editOperations := ds.PreallocatedStack[*EditOperation](initialEditOperations) + for range initialEditOperations { + editOperations.Push(new(EditOperation)) } return &Scene{ - deleteSubscriptions: observer.NewSubscriptionSet[DeleteCallback](), - purgeSubscriptions: observer.NewSubscriptionSet[DeleteCallback](), + registry: scope.registry, + + enterSubscriptions: observer.NewSubscriptionSet[ConditionalCallback](), + exitSubscriptions: observer.NewSubscriptionSet[ConditionalCallback](), + + freeEntityIndices: ds.EmptyStack[uint32](), + entities: nil, - maxEntityCount: maxEntityCount, - entityMask: newBitmask(), - freeHandleIndices: freeHandleIndices, - handles: make([]entityHandle, maxEntityCount), + archetypePool: ds.EmptyStack[*internal.Archetype](), + archetypes: make(map[internal.TypeMask]*internal.Archetype), - freeRevision: uint32(1), - freeComponentIndex: uint64(0), + commandBuffer: internal.NewBuffer(1024), // 1KB initial capacity + stager: internal.NewStager(scope.registry), - results: mem.NewSparseAllocator[Result](), + readOperations: readOperations, + editOperations: editOperations, } } -// Scene represents a collection of ECS entities. +// Scene is the central ECS container. It stores entities and their +// components in archetype-grouped tables and provides methods for +// creating, deleting, reading, editing, and querying entities, as well +// as subscribing to structural change events. type Scene struct { - deleteSubscriptions *observer.SubscriptionSet[DeleteCallback] - purgeSubscriptions *observer.SubscriptionSet[DeleteCallback] + registry *internal.Registry - maxEntityCount int - entityMask *bitmask - freeHandleIndices *ds.Stack[uint32] - handles []entityHandle + enterSubscriptions *observer.SubscriptionSet[ConditionalCallback] + exitSubscriptions *observer.SubscriptionSet[ConditionalCallback] + + freeEntityIndices *ds.Stack[uint32] + entities []internal.Entity + + archetypePool *ds.Stack[*internal.Archetype] + archetypes map[internal.TypeMask]*internal.Archetype + + commandBuffer *internal.Buffer + stager *internal.Stager + + readOperations *ds.Stack[*ReadOperation] + editOperations *ds.Stack[*EditOperation] + queryDepth uint32 + freezeDepth uint32 + inOperationBlock bool + inQueueProcessing bool +} - freeRevision uint32 - freeComponentIndex uint64 +// Delete releases all resources held by the scene. The scene must not +// be used after this call. +func (s *Scene) Delete() { + s.enterSubscriptions.Clear() + s.exitSubscriptions.Clear() + for _, archetype := range s.archetypes { + archetype.Destroy() + } + s.stager.Destroy() +} - results *mem.SparseAllocator[Result] +// SubscribeEnter registers a callback that fires whenever an entity +// transitions into satisfying condition. The callback receives the ID +// of the entity that triggered the transition. Call Delete on the +// returned [EntitySubscription] to unsubscribe. +func (s *Scene) SubscribeEnter(condition Condition, callback EntityCallback) *EntitySubscription { + return s.enterSubscriptions.Subscribe(ConditionalCallback{ + condition: condition, + callback: callback, + }) } -// MaxEntityCount returns the maximum number of entities that can be managed -// by this scene at any given point in time (this includes entities marked -// for deletion that have not been purged yet). -func (s *Scene) MaxEntityCount() int { - return s.maxEntityCount +// SubscribeExit registers a callback that fires whenever an entity +// transitions out of satisfying condition. The callback receives the ID +// of the entity that triggered the transition. Call Delete on the +// returned [EntitySubscription] to unsubscribe. +func (s *Scene) SubscribeExit(condition Condition, callback EntityCallback) *EntitySubscription { + return s.exitSubscriptions.Subscribe(ConditionalCallback{ + condition: condition, + callback: callback, + }) } -// SubscribeDelete adds a callback to be executed before an entity is fully -// deleted. -func (s *Scene) SubscribeDelete(callback DeleteCallback) *DeleteSubscription { - return s.deleteSubscriptions.Subscribe(callback) +// Freeze increments the scene's freeze depth, preventing mutations from +// being committed. While the scene is frozen, calls to [Scene.CreateEntity], +// [Scene.DeleteEntity], and [Scene.EditEntity] are still accepted but their +// effects — archetype rearrangement and subscription dispatch — are deferred +// until the freeze depth returns to zero. +// +// The primary use case is retaining component pointers obtained via +// [GetComponent] outside of a [Scene.ReadEntity] callback. Pointers remain +// stable for as long as the scene is frozen. +// +// Freeze calls may be nested; each must be paired with exactly one +// [Scene.Unfreeze] call. +// +// Note: entities created while the scene is frozen are not yet committed. +// Their IDs are valid for [Scene.HasEntity], [Scene.DeleteEntity], and +// [Scene.EditEntity], but calling [Scene.ReadEntity] or [Scene.CheckEntity] +// on them before [Scene.Unfreeze] panics. +func (s *Scene) Freeze() { + s.freezeDepth++ } -// CreateEntity creates a new entity in this scene. -func (s *Scene) CreateEntity() EntityID { - s.freeRevision++ - index := s.freeHandleIndices.Pop() - s.entityMask.Set(index, true) - s.handles[index] = entityHandle{ - components: componentMask(0), - revision: s.freeRevision, - isPendingDeletion: false, +// Unfreeze decrements the scene's freeze depth. When it reaches zero, all +// mutations buffered since the last [Scene.Freeze] are committed. Any +// component pointers retained during the freeze must not be used after this +// call, as the underlying storage may be rearranged during commit. +// +// Panics if called without a prior matching [Scene.Freeze]. +func (s *Scene) Unfreeze() { + if s.freezeDepth == 0 { + panic("unbalanced Unfreeze call") } - return EntityID{ - index: index, - revision: s.freeRevision, + s.freezeDepth-- + + s.processQueue() +} + +// CreateEntity allocates a new entity and returns its [ID]. If fn is +// not nil, fn is called with an [EditOperation] so that initial +// components can be added before the entity is committed to the scene. +// +// CreateEntity may be called during a query; the creation is deferred +// until the query completes. +func (s *Scene) CreateEntity(fn EditOperationFunc) ID { + s.verifyOutsideOperation() + + index := s.allocateEntityIndex() + desc := &s.entities[index] + desc.Revive(index) + + stageRow := s.stager.Grow() + + internal.WriteToBuffer(s.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeCreateEntity, + }) + internal.WriteToBuffer(s.commandBuffer, internal.CreateEntityCommand{ + EntityID: desc.ID(), + StageRow: stageRow, + }) + + if fn != nil { + editOperation := s.allocateEditOperation() + defer s.releaseEditOperation(editOperation) + + *editOperation = EditOperation{ + stager: s.stager, + commandBuffer: s.commandBuffer, + stageRow: stageRow, + } + s.inOperationBlock = true + fn(editOperation) + s.inOperationBlock = false } + + internal.WriteToBuffer(s.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeEndOfSequence, + }) + + s.processQueue() + + return fromInternalID(desc.ID()) } -// HasEntity returns whether the specified entity is still valid and -// part of this scene (i.e. it has not been marked for deletion and purged). -func (s *Scene) HasEntity(entityID EntityID) bool { - handle := &s.handles[entityID.index] - return handle.revision == entityID.revision +// DeleteEntity removes the entity and all its components from the +// scene. Any component pointers previously obtained for this entity +// must not be used after this call. +// +// DeleteEntity may be called during a query; the deletion is deferred +// until the query completes. +func (s *Scene) DeleteEntity(id ID) { + s.verifyOutsideOperation() + + internal.WriteToBuffer(s.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeDeleteEntity, + }) + internal.WriteToBuffer(s.commandBuffer, internal.DeleteEntityCommand{ + EntityID: internal.NewID(id.index, id.revision), + }) + + s.processQueue() } -// DeleteEntity marks an entity for deletion. -func (s *Scene) DeleteEntity(entityID EntityID) { - handle := &s.handles[entityID.index] - if handle.revision != entityID.revision { - return +// HasEntity reports whether id refers to a live entity in the scene. +func (s *Scene) HasEntity(id ID) bool { + s.verifyOutsideOperation() + + _, ok := s.getEntityDescriptor(id) + return ok +} + +// CheckEntity reports whether the entity identified by id satisfies +// condition. Returns false for invalid or deleted IDs. +func (s *Scene) CheckEntity(id ID, condition Condition) bool { + s.verifyOutsideOperation() + + desc, ok := s.getEntityDescriptor(id) + if !ok { + return false + } + archetype := desc.Archetype() + if archetype == nil { + panic("cannot read entity that is being deferred for creation") + } + + return condition.isSatisfiedBy(archetype.TypeMask()) +} + +// ReadEntity calls fn with a [ReadOperation] scoped to the entity +// identified by id. Use [GetComponent] or [InjectComponent] inside fn +// to retrieve component values. +// +// Panics if the entity does not exist. +func (s *Scene) ReadEntity(id ID, fn func(*ReadOperation)) { + s.verifyOutsideOperation() + + desc, ok := s.getEntityDescriptor(id) + if !ok { + panic("entity does not exist") } - handle.isPendingDeletion = true + + archetype := desc.Archetype() + if archetype == nil { + panic("cannot read entity that is being deferred for creation") + } + + mask := archetype.TypeMask() + row := desc.ArchetypeRow() + columnIDs, lookup := archetype.ComponentColumnIDs() + + readOperation := s.allocateReadOperation() + defer s.releaseReadOperation(readOperation) + readOperation.mask = mask + readOperation.componentLookup = lookup + readOperation.componentColumnIDs = columnIDs + readOperation.row = row + + s.inOperationBlock = true + fn(readOperation) + s.inOperationBlock = false } -// Query searches for entities that satisfy all specified conditions. -func (s *Scene) Query(conditions ...Condition) *Result { - var queryCondition Condition - for _, condition := range conditions { - queryCondition.apply(condition) +// EditEntity calls fn with an [EditOperation] for the entity identified +// by id. Use [AddComponent], [RemoveComponent], and [ReplaceComponent] +// inside fn to stage structural or value changes. +// +// Panics if a component is added that the entity already has, or one is +// removed or replaced that the entity does not have, as determined by +// the virtual component state after each prior operation in the same +// edit. When multiple operations target the same component type, only +// the last one takes effect. +// +// EditEntity may be called during a query; the edit is deferred until +// the query completes. +func (s *Scene) EditEntity(id ID, fn EditOperationFunc) { + s.verifyOutsideOperation() + + stageRow := s.stager.Grow() + + internal.WriteToBuffer(s.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeEditEntity, + }) + internal.WriteToBuffer(s.commandBuffer, internal.EditEntityCommand{ + EntityID: internal.NewID(id.index, id.revision), + StageRow: stageRow, + }) + + editOperation := s.allocateEditOperation() + defer s.releaseEditOperation(editOperation) + + *editOperation = EditOperation{ + stager: s.stager, + commandBuffer: s.commandBuffer, + stageRow: stageRow, } - result := s.results.Allocate() - result.scene = s - if result.entityMask == nil { - result.entityMask = newBitmask() + s.inOperationBlock = true + fn(editOperation) + s.inOperationBlock = false + + internal.WriteToBuffer(s.commandBuffer, internal.CommandHeader{ + CommandType: internal.CommandTypeEndOfSequence, + }) + + s.processQueue() +} + +// QueryEntities calls yield for every entity that satisfies condition, +// passing a [ReadOperation] through which its components can be read. +// Returning false from yield stops the iteration early. +// +// Mutations made during the query via [Scene.EditEntity], +// [Scene.CreateEntity], or [Scene.DeleteEntity] are deferred and +// applied after iteration completes. +func (s *Scene) QueryEntities(condition Condition, yield func(ID, *ReadOperation) bool) { + s.verifyOutsideOperation() + + s.queryDepth++ + + readOperation := s.allocateReadOperation() + defer s.releaseReadOperation(readOperation) + +iteration: + for mask, archetype := range s.archetypes { + if !condition.isSatisfiedBy(mask) { + continue + } + + columnIDs, lookup := archetype.ComponentColumnIDs() + + readOperation.mask = mask + readOperation.componentLookup = lookup + readOperation.componentColumnIDs = columnIDs + + for row := internal.Row(0); uint32(row) < archetype.Size(); row++ { + readOperation.row = row + intID := archetype.IDColumn().Value(row) + if !yield(fromInternalID(intID), readOperation) { + break iteration + } + } + } + + s.queryDepth-- + s.processQueue() +} + +// QueryEntitiesIter returns a range iterator over entities that satisfy +// condition. It is equivalent to [Scene.QueryEntities] and carries the +// same deferred-mutation semantics. +func (s *Scene) QueryEntitiesIter(condition Condition) iter.Seq2[ID, *ReadOperation] { + return func(yield func(ID, *ReadOperation) bool) { + s.QueryEntities(condition, yield) + } +} + +func (s *Scene) verifyOutsideOperation() { + if s.inOperationBlock { + panic("cannot call this method from inside an operation block") + } +} + +func (s *Scene) allocateEntityIndex() uint32 { + var index uint32 + if s.freeEntityIndices.IsEmpty() { + index = uint32(len(s.entities)) + s.entities = append(s.entities, internal.Entity{}) } else { - result.entityMask.Clear() + index = s.freeEntityIndices.Pop() } - for entityIndex := range s.entityMask.ActiveIter() { - if handle := &s.handles[entityIndex]; queryCondition.isSatisfied(handle) { - result.entityMask.Set(entityIndex, true) + return index +} + +func (s *Scene) releaseEntityIndex(index uint32) { + s.freeEntityIndices.Push(index) +} + +func (s *Scene) getEntityDescriptor(id ID) (*internal.Entity, bool) { + if id == NilID { + return nil, false + } + desc := &s.entities[id.index] + if !desc.HasRevision(id.revision) { + return nil, false + } + return desc, true +} + +func (s *Scene) borrowArchetypeRow(mask internal.TypeMask) (*internal.Archetype, internal.Row) { + archetype, ok := s.archetypes[mask] + if !ok { + archetype = s.allocateArchetype(mask) + } + + row := archetype.Grow() + return archetype, row +} + +func (s *Scene) releaseArchetypeRow(archetype *internal.Archetype, row internal.Row) { + lastRow := archetype.LastRow() + if row != lastRow { + lastRowID := archetype.IDColumn().Value(lastRow) + if lastRowID != internal.NilID { + lastRowDesc := &s.entities[lastRowID.Index] + lastRowDesc.Assign(archetype, row) } + archetype.CopyRow(row, lastRow) + } + + archetype.Shrink() + + if archetype.IsEmpty() { + s.releaseArchetype(archetype) } +} + +func (s *Scene) allocateArchetype(mask internal.TypeMask) *internal.Archetype { + var result *internal.Archetype + if s.archetypePool.IsEmpty() { + result = internal.NewArchetype(s.registry) + } else { + result = s.archetypePool.Pop() + } + + result.Revive(mask) + + s.archetypes[mask] = result + return result } -// Purge removes any entities that have been marked for deletion. -// -// All delete subscriptions will be notified at this point in time. -func (s *Scene) Purge() { - for entityIndex := range s.entityMask.ActiveIter() { - if handle := &s.handles[entityIndex]; handle.isPendingDeletion { - s.notifyDelete(EntityID{ - index: entityIndex, - revision: handle.revision, - }) - s.entityMask.Set(entityIndex, false) - s.freeHandleIndices.Push(entityIndex) +func (s *Scene) releaseArchetype(archetype *internal.Archetype) { + mask := archetype.TypeMask() + delete(s.archetypes, mask) + + archetype.Destroy() + + s.archetypePool.Push(archetype) +} + +func (s *Scene) allocateReadOperation() *ReadOperation { + if s.readOperations.IsEmpty() { + return &ReadOperation{} + } + return s.readOperations.Pop() +} + +func (s *Scene) releaseReadOperation(op *ReadOperation) { + s.readOperations.Push(op) +} + +func (s *Scene) allocateEditOperation() *EditOperation { + if s.editOperations.IsEmpty() { + return &EditOperation{} + } + return s.editOperations.Pop() +} + +func (s *Scene) releaseEditOperation(op *EditOperation) { + s.editOperations.Push(op) +} + +func (s *Scene) processQueue() { + if s.inQueueProcessing || s.freezeDepth > 0 || s.queryDepth > 0 { + return + } + s.inQueueProcessing = true + defer func() { + s.inQueueProcessing = false + }() + + for s.commandBuffer.HasMoreData() { + header := internal.ReadFromBuffer[internal.CommandHeader](s.commandBuffer) + switch header.CommandType { + + case internal.CommandTypeCreateEntity: + cmd := internal.ReadFromBuffer[internal.CreateEntityCommand](s.commandBuffer) + s.processCreateEntityCommand(cmd) + + case internal.CommandTypeEditEntity: + cmd := internal.ReadFromBuffer[internal.EditEntityCommand](s.commandBuffer) + s.processEditEntityCommand(cmd) + + case internal.CommandTypeDeleteEntity: + cmd := internal.ReadFromBuffer[internal.DeleteEntityCommand](s.commandBuffer) + s.processDeleteEntityCommand(cmd) + + default: + panic(fmt.Errorf("unexpected command type %v in scene command buffer", header.CommandType)) } } + + s.commandBuffer.Reset() + s.stager.Clear() } -// Delete removes this scene and releases any associated resources. -func (s *Scene) Delete() {} +func (s *Scene) processCreateEntityCommand(cmd internal.CreateEntityCommand) { + id := fromInternalID(cmd.EntityID) -func (s *Scene) newComponentType() componentMask { - if s.freeComponentIndex >= MaxComponentCount { - panic("max number of components reached") + desc, ok := s.getEntityDescriptor(id) + if !ok { + panic(fmt.Errorf("entity with ID %v does not exist", id)) } - result := componentMask(1 << s.freeComponentIndex) - s.freeComponentIndex++ - return result + + oldMask := opt.Unspecified[internal.TypeMask]() // starting from limbo + newMask, changes := s.processComponentCommands(internal.EmptyTypeMask()) + + archetype, row := s.borrowArchetypeRow(newMask) + changes.EachType(func(id internal.TypeID) { + storage := s.registry.Storage(id) + newColumnID := archetype.ComponentColumnID(id) + stageColumnID := s.stager.ComponentColumnID(id) + storage.CopyCell(newColumnID, row, stageColumnID, cmd.StageRow) + }) + + desc.Assign(archetype, row) + + s.dispatchEnterEvent(id, oldMask, newMask) } -func (s *Scene) assignComponent(entityID EntityID, mask componentMask) { - handle := &s.handles[entityID.index] - if handle.revision != entityID.revision { - panic("cannot add component to deleted entity") +func (s *Scene) processEditEntityCommand(cmd internal.EditEntityCommand) { + id := fromInternalID(cmd.EntityID) + + desc, ok := s.getEntityDescriptor(id) + if !ok { + panic(fmt.Errorf("entity with ID %v does not exist", id)) + } + + oldMask := desc.Archetype().TypeMask() + newMask, changes := s.processComponentCommands(oldMask) + + if oldMask != newMask { + s.dispatchExitEvent(id, oldMask, newMask) + + oldArchetype := desc.Archetype() + oldRow := desc.ArchetypeRow() + + newArchetype, newRow := s.borrowArchetypeRow(newMask) + newMask.EachType(func(id internal.TypeID) { + storage := s.registry.Storage(id) + newColumnID := newArchetype.ComponentColumnID(id) + if changes.HasType(id) { + stageColumnID := s.stager.ComponentColumnID(id) + storage.CopyCell(newColumnID, newRow, stageColumnID, cmd.StageRow) + } else { + oldColumnID := oldArchetype.ComponentColumnID(id) + storage.CopyCell(newColumnID, newRow, oldColumnID, oldRow) + } + }) + s.releaseArchetypeRow(oldArchetype, oldRow) + + desc.Assign(newArchetype, newRow) + + s.dispatchEnterEvent(id, opt.V(oldMask), newMask) + } else { + archetype := desc.Archetype() + row := desc.ArchetypeRow() + + changes.EachType(func(id internal.TypeID) { + storage := s.registry.Storage(id) + newColumnID := archetype.ComponentColumnID(id) + stageColumnID := s.stager.ComponentColumnID(id) + storage.CopyCell(newColumnID, row, stageColumnID, cmd.StageRow) + }) } - handle.components |= mask } -func (s *Scene) removeComponent(entityID EntityID, mask componentMask) { - handle := &s.handles[entityID.index] - if handle.revision != entityID.revision { - panic("cannot remove component from deleted entity") +func (s *Scene) processComponentCommands(mask internal.TypeMask) (internal.TypeMask, internal.TypeMask) { + changes := internal.EmptyTypeMask() + +commandLoop: + for s.commandBuffer.HasMoreData() { + header := internal.ReadFromBuffer[internal.CommandHeader](s.commandBuffer) + switch header.CommandType { + + case internal.CommandTypeAddComponent: + cmd := internal.ReadFromBuffer[internal.AddComponentCommand](s.commandBuffer) + if mask.HasType(cmd.TypeID) { + panic(fmt.Errorf("cannot add component of type %v that the entity already has", cmd.TypeID)) + } + mask.AddType(cmd.TypeID) + changes.AddType(cmd.TypeID) + + case internal.CommandTypeRemoveComponent: + cmd := internal.ReadFromBuffer[internal.RemoveComponentCommand](s.commandBuffer) + if !mask.HasType(cmd.TypeID) { + panic(fmt.Errorf("cannot remove component of type %v that the entity does not have", cmd.TypeID)) + } + mask.RemoveType(cmd.TypeID) + changes.RemoveType(cmd.TypeID) + + case internal.CommandTypeReplaceComponent: + cmd := internal.ReadFromBuffer[internal.ReplaceComponentCommand](s.commandBuffer) + if !mask.HasType(cmd.TypeID) { + panic(fmt.Errorf("cannot replace component of type %v that the entity does not have", cmd.TypeID)) + } + changes.AddType(cmd.TypeID) + + case internal.CommandTypeEndOfSequence: + break commandLoop + + default: + panic(fmt.Errorf("unexpected command type %v in EditEntity command buffer", header.CommandType)) + } } - handle.components &= ^mask + + return mask, changes } -func (s *Scene) hasComponent(entityID EntityID, mask componentMask) bool { - handle := &s.handles[entityID.index] - if handle.revision != entityID.revision { - panic("cannot reference component of deleted entity") +func (s *Scene) processDeleteEntityCommand(cmd internal.DeleteEntityCommand) { + id := fromInternalID(cmd.EntityID) + + desc, ok := s.getEntityDescriptor(id) + if !ok { + panic(fmt.Errorf("entity with ID %v does not exist", id)) } - return (handle.components & mask) == mask + + oldMask := desc.Archetype().TypeMask() + + s.dispatchExitEvent(id, oldMask, internal.EmptyTypeMask()) + + s.releaseArchetypeRow(desc.Destroy()) + s.releaseEntityIndex(id.index) } -func (s *Scene) notifyDelete(entityID EntityID) { - for callback := range s.deleteSubscriptions.CallbacksIter() { - callback(entityID) +func (s *Scene) dispatchExitEvent(id ID, oldMask, newMask internal.TypeMask) { + for subscription := range s.exitSubscriptions.CallbacksIter() { + condition := subscription.condition + if !condition.isSatisfiedBy(oldMask) { + continue + } + if condition.isSatisfiedBy(newMask) { + continue + } + subscription.callback(id) } - for callback := range s.purgeSubscriptions.CallbacksIter() { - callback(entityID) +} + +func (s *Scene) dispatchEnterEvent(id ID, oldMask opt.T[internal.TypeMask], newMask internal.TypeMask) { + for subscription := range s.enterSubscriptions.CallbacksIter() { + condition := subscription.condition + if oldMask.Specified && condition.isSatisfiedBy(oldMask.Value) { + continue + } + if !condition.isSatisfiedBy(newMask) { + continue + } + subscription.callback(id) } } diff --git a/game/ecs/scene_test.go b/game/ecs/scene_test.go new file mode 100644 index 00000000..1056259f --- /dev/null +++ b/game/ecs/scene_test.go @@ -0,0 +1,1039 @@ +package ecs_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mokiat/lacking/game/ecs" +) + +var _ = Describe("Scene", func() { + type Position struct { + X, Y int + } + type Age struct { + Value int + } + type Name struct { + Value string + } + type Identification struct { + ID uint32 + } + type Unused struct{} // a tag component + + var ( + scope *ecs.Scope + positionType ecs.ComponentType[Position] + ageType ecs.ComponentType[Age] + nameType ecs.ComponentType[Name] + identificationType ecs.ComponentType[Identification] + unusedType ecs.ComponentType[Unused] + scene *ecs.Scene + ) + + BeforeEach(func() { + scope = ecs.NewScope() + positionType = ecs.Type[Position](scope) + ageType = ecs.Type[Age](scope) + nameType = ecs.Type[Name](scope) + identificationType = ecs.Type[Identification](scope) + _ = identificationType // TODO: REMOVE + unusedType = ecs.Type[Unused](scope) + scene = ecs.NewScene(scope) + }) + + Specify("can create entity", func() { + id := scene.CreateEntity(nil) + Expect(id).ToNot(Equal(ecs.NilID)) + }) + + Specify("entities have unique IDs", func() { + id1 := scene.CreateEntity(nil) + Expect(id1).ToNot(Equal(ecs.NilID)) + + id2 := scene.CreateEntity(nil) + Expect(id2).ToNot(Equal(ecs.NilID)) + + Expect(id2).ToNot(Equal(id1)) + }) + + Specify("can check for entity existence", func() { + id := scene.CreateEntity(nil) + Expect(scene.HasEntity(id)).To(BeTrue()) + + Expect(scene.HasEntity(ecs.NilID)).To(BeFalse()) + }) + + Specify("can delete entity", func() { + id := scene.CreateEntity(nil) + Expect(scene.HasEntity(id)).To(BeTrue()) + + scene.DeleteEntity(id) + Expect(scene.HasEntity(id)).To(BeFalse()) + }) + + Specify("deleting an entity does not affect other entities", func() { + id1 := scene.CreateEntity(nil) + id2 := scene.CreateEntity(nil) + + scene.DeleteEntity(id1) + + Expect(scene.HasEntity(id1)).To(BeFalse()) + Expect(scene.HasEntity(id2)).To(BeTrue()) + }) + + Specify("can add components to entity", func() { + id := scene.CreateEntity(nil) + + pos := Position{X: 1, Y: 2} + name := Name{Value: "Alice"} + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, pos) + ecs.AddComponent(op, nameType, name) + }) + }) + + Specify("can create entity with initial components", func() { + id := scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + }) + + Expect(scene.CheckEntity(id, ecs.HasComponent(positionType))).To(BeTrue()) + Expect(scene.CheckEntity(id, ecs.HasComponent(nameType))).To(BeTrue()) + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) + + var pos *Position + var name *Name + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + name = ecs.GetComponent(op, nameType) + }) + Expect(*pos).To(Equal(Position{X: 1, Y: 2})) + Expect(*name).To(Equal(Name{Value: "Alice"})) + }) + + Specify("creation callback fires enter subscriptions with initial components", func() { + var entered []ecs.ID + scene.SubscribeEnter(ecs.HasComponent(positionType), func(id ecs.ID) { + entered = append(entered, id) + }) + + id := scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(entered).To(ConsistOf(id)) + }) + + When("having an entity with components", func() { + var id ecs.ID + + BeforeEach(func() { + id = scene.CreateEntity(nil) + + pos := Position{X: 1, Y: 2} + name := Name{Value: "Alice"} + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, pos) + ecs.AddComponent(op, nameType, name) + }) + }) + + Specify("can check whether it satisfies a positive condition", func() { + ok := scene.CheckEntity(id, ecs.HasComponent(positionType)) + Expect(ok).To(BeTrue()) + + ok = scene.CheckEntity(id, ecs.HasComponent(nameType)) + Expect(ok).To(BeTrue()) + + ok = scene.CheckEntity(id, ecs.HasComponent(ageType)) + Expect(ok).To(BeFalse()) + }) + + Specify("can check whether it satisfies a negative condition", func() { + ok := scene.CheckEntity(id, ecs.LacksComponent(positionType)) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.LacksComponent(nameType)) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.LacksComponent(ageType)) + Expect(ok).To(BeTrue()) + }) + + Specify("can check whether it satisfies a composite condition", func() { + ok := scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(nameType), + ecs.LacksComponent(ageType), + )) + Expect(ok).To(BeTrue()) + + ok = scene.CheckEntity(id, ecs.Conditions( + ecs.LacksComponent(positionType), + ecs.HasComponent(nameType), + ecs.LacksComponent(ageType), + )) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(positionType), + ecs.LacksComponent(nameType), + ecs.LacksComponent(ageType), + )) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(nameType), + ecs.HasComponent(ageType), + )) + Expect(ok).To(BeFalse()) + }) + + When("a component is removed", func() { + BeforeEach(func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + }) + }) + + Specify("the entity no longer satisfies conditions requiring that component", func() { + ok := scene.CheckEntity(id, ecs.HasComponent(positionType)) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(nameType), + )) + Expect(ok).To(BeFalse()) + }) + }) + + When("the entity is deleted", func() { + BeforeEach(func() { + scene.DeleteEntity(id) + }) + + Specify("the entity no longer satisfies any conditions", func() { + ok := scene.CheckEntity(id, ecs.HasComponent(positionType)) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.HasComponent(nameType)) + Expect(ok).To(BeFalse()) + + ok = scene.CheckEntity(id, ecs.HasComponent(ageType)) + Expect(ok).To(BeFalse()) + }) + }) + + Specify("can read components from entity", func() { + var ( + pos *Position + name *Name + age *Age + ) + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + name = ecs.GetComponent(op, nameType) + age = ecs.GetComponent(op, ageType) + }) + + Expect(pos).ToNot(BeNil()) + Expect(*pos).To(Equal(Position{X: 1, Y: 2})) + + Expect(name).ToNot(BeNil()) + Expect(*name).To(Equal(Name{Value: "Alice"})) + + Expect(age).To(BeNil()) + }) + + Specify("can replace a component value", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.ReplaceComponent(op, positionType, Position{X: 10, Y: 20}) + }) + + var pos *Position + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + }) + Expect(*pos).To(Equal(Position{X: 10, Y: 20})) + }) + + Specify("can remove and re-add a component in the same edit, updating its value", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + ecs.AddComponent(op, positionType, Position{X: 10, Y: 20}) + }) + + Expect(scene.CheckEntity(id, ecs.HasComponent(positionType))).To(BeTrue()) + + var pos *Position + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + }) + Expect(*pos).To(Equal(Position{X: 10, Y: 20})) + }) + + Specify("adding and removing the same component in the same edit is a no-op", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.RemoveComponent(op, ageType) + }) + + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) + }) + }) + + When("having multiple entities with various component combinations", func() { + var ( + entityPosName ecs.ID + entityPosAge ecs.ID + entityNameOnly ecs.ID + ) + + BeforeEach(func() { + entityPosName = scene.CreateEntity(nil) + scene.EditEntity(entityPosName, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + }) + + entityPosAge = scene.CreateEntity(nil) + scene.EditEntity(entityPosAge, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.AddComponent(op, ageType, Age{Value: 30}) + }) + + entityNameOnly = scene.CreateEntity(nil) + scene.EditEntity(entityNameOnly, func(op *ecs.EditOperation) { + ecs.AddComponent(op, nameType, Name{Value: "Bob"}) + }) + }) + + Specify("querying by a single condition returns all matching entities", func() { + var found []ecs.ID + scene.QueryEntities(ecs.HasComponent(positionType), func(id ecs.ID, _ *ecs.ReadOperation) bool { + found = append(found, id) + return true + }) + Expect(found).To(ConsistOf(entityPosName, entityPosAge)) + }) + + Specify("querying returns correct component values for each entity", func() { + positions := make(map[ecs.ID]Position) + scene.QueryEntities(ecs.HasComponent(positionType), func(id ecs.ID, op *ecs.ReadOperation) bool { + positions[id] = *ecs.GetComponent(op, positionType) + return true + }) + Expect(positions[entityPosName]).To(Equal(Position{X: 1, Y: 2})) + Expect(positions[entityPosAge]).To(Equal(Position{X: 3, Y: 4})) + }) + + Specify("querying with a composite condition filters correctly", func() { + var found []ecs.ID + scene.QueryEntities(ecs.Conditions( + ecs.HasComponent(positionType), + ecs.LacksComponent(ageType), + ), func(id ecs.ID, _ *ecs.ReadOperation) bool { + found = append(found, id) + return true + }) + Expect(found).To(ConsistOf(entityPosName)) + }) + + Specify("querying with no matching entities yields nothing", func() { + var found []ecs.ID + scene.QueryEntities(ecs.HasComponent(unusedType), func(id ecs.ID, _ *ecs.ReadOperation) bool { + found = append(found, id) + return true + }) + Expect(found).To(BeEmpty()) + }) + + Specify("query can be stopped early by returning false", func() { + count := 0 + scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, _ *ecs.ReadOperation) bool { + count++ + return false + }) + Expect(count).To(Equal(1)) + }) + + Specify("querying via iterator returns all matching entities", func() { + var found []ecs.ID + for id := range scene.QueryEntitiesIter(ecs.HasComponent(nameType)) { + found = append(found, id) + } + Expect(found).To(ConsistOf(entityPosName, entityNameOnly)) + }) + + Specify("nested query returns correct results", func() { + pairs := make(map[ecs.ID][]ecs.ID) + scene.QueryEntities(ecs.HasComponent(positionType), func(outerID ecs.ID, _ *ecs.ReadOperation) bool { + scene.QueryEntities(ecs.HasComponent(nameType), func(innerID ecs.ID, _ *ecs.ReadOperation) bool { + pairs[outerID] = append(pairs[outerID], innerID) + return true + }) + return true + }) + Expect(pairs[entityPosName]).To(ConsistOf(entityPosName, entityNameOnly)) + Expect(pairs[entityPosAge]).To(ConsistOf(entityPosName, entityNameOnly)) + }) + + Specify("nested query does not corrupt outer read operation", func() { + scene.QueryEntities(ecs.HasComponent(positionType), func(outerID ecs.ID, outerOp *ecs.ReadOperation) bool { + outerPos := ecs.GetComponent(outerOp, positionType) + + scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, _ *ecs.ReadOperation) bool { + return true + }) + + Expect(ecs.GetComponent(outerOp, positionType)).To(Equal(outerPos)) + return true + }) + }) + + Specify("editing an entity during a query is deferred until after the query", func() { + var positionsDuringQuery []Position + scene.QueryEntities(ecs.HasComponent(positionType), func(id ecs.ID, op *ecs.ReadOperation) bool { + positionsDuringQuery = append(positionsDuringQuery, *ecs.GetComponent(op, positionType)) + scene.EditEntity(id, func(editOp *ecs.EditOperation) { + ecs.ReplaceComponent(editOp, positionType, Position{X: 99, Y: 99}) + }) + return true + }) + Expect(positionsDuringQuery).To(ConsistOf(Position{X: 1, Y: 2}, Position{X: 3, Y: 4})) + + var positionsAfterQuery []Position + scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, op *ecs.ReadOperation) bool { + positionsAfterQuery = append(positionsAfterQuery, *ecs.GetComponent(op, positionType)) + return true + }) + Expect(positionsAfterQuery).To(ConsistOf(Position{X: 99, Y: 99}, Position{X: 99, Y: 99})) + }) + + Specify("creating an entity during a query is deferred until after the query", func() { + var createdID ecs.ID + visitCount := 0 + scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, _ *ecs.ReadOperation) bool { + visitCount++ + if createdID == ecs.NilID { + createdID = scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 99, Y: 99}) + }) + } + return true + }) + Expect(visitCount).To(Equal(2)) + Expect(scene.HasEntity(createdID)).To(BeTrue()) + Expect(scene.CheckEntity(createdID, ecs.HasComponent(positionType))).To(BeTrue()) + }) + + Specify("deleting an entity during a query is deferred until after the query", func() { + var deletedID ecs.ID + visitCount := 0 + scene.QueryEntities(ecs.HasComponent(positionType), func(id ecs.ID, _ *ecs.ReadOperation) bool { + visitCount++ + if deletedID == ecs.NilID { + deletedID = id + scene.DeleteEntity(id) + } + return true + }) + Expect(visitCount).To(Equal(2)) + Expect(scene.HasEntity(deletedID)).To(BeFalse()) + }) + + Specify("deferred mutations from a nested query are applied after the outer query", func() { + scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, _ *ecs.ReadOperation) bool { + scene.QueryEntities(ecs.HasComponent(nameType), func(id ecs.ID, _ *ecs.ReadOperation) bool { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 99}) + }) + return true + }) + Expect(scene.CheckEntity(entityPosName, ecs.HasComponent(ageType))).To(BeFalse()) + Expect(scene.CheckEntity(entityNameOnly, ecs.HasComponent(ageType))).To(BeFalse()) + return false // stop after one outer iteration to avoid duplicate add + }) + Expect(scene.CheckEntity(entityPosName, ecs.HasComponent(ageType))).To(BeTrue()) + Expect(scene.CheckEntity(entityNameOnly, ecs.HasComponent(ageType))).To(BeTrue()) + }) + }) + + When("subscribing to entity enter events", func() { + var entered []ecs.ID + + BeforeEach(func() { + entered = nil + }) + + When("condition is HasComponent", func() { + BeforeEach(func() { + scene.SubscribeEnter(ecs.HasComponent(positionType), func(id ecs.ID) { + entered = append(entered, id) + }) + }) + + Specify("does not fire when entity is created with no components", func() { + scene.CreateEntity(nil) + Expect(entered).To(BeEmpty()) + }) + + Specify("fires when entity gains the required component", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(entered).To(ConsistOf(id)) + }) + + Specify("does not fire again when another component is added while condition remains satisfied", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 30}) + }) + Expect(entered).To(BeEmpty()) + }) + + Specify("fires again after entity re-gains the component", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + }) + Expect(entered).To(ConsistOf(id)) + }) + + Specify("does not fire when entity is deleted", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.DeleteEntity(id) + Expect(entered).To(BeEmpty()) + }) + + Specify("all subscribers receive the notification", func() { + var secondEntered []ecs.ID + scene.SubscribeEnter(ecs.HasComponent(positionType), func(id ecs.ID) { + secondEntered = append(secondEntered, id) + }) + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(entered).To(ConsistOf(id)) + Expect(secondEntered).To(ConsistOf(id)) + }) + + Specify("stops firing after subscription is deleted", func() { + sub := scene.SubscribeEnter(ecs.HasComponent(ageType), func(id ecs.ID) { + entered = append(entered, id) + }) + sub.Delete() + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 25}) + }) + Expect(entered).To(BeEmpty()) + }) + + Specify("fires for a composite condition only when all components are present", func() { + var compositeEntered []ecs.ID + scene.SubscribeEnter(ecs.Conditions( + ecs.HasComponent(positionType), + ecs.HasComponent(nameType), + ), func(id ecs.ID) { + compositeEntered = append(compositeEntered, id) + }) + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(compositeEntered).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + }) + Expect(compositeEntered).To(ConsistOf(id)) + }) + + Specify("does not fire when a component is replaced in place", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.ReplaceComponent(op, positionType, Position{X: 3, Y: 4}) + }) + Expect(entered).To(BeEmpty()) + }) + + Specify("does not fire when a component is removed and re-added in the same edit", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + }) + Expect(entered).To(BeEmpty()) + }) + }) + + When("condition is LacksComponent", func() { + BeforeEach(func() { + scene.SubscribeEnter(ecs.LacksComponent(positionType), func(id ecs.ID) { + entered = append(entered, id) + }) + }) + + Specify("fires when entity is created (starts without the excluded component)", func() { + id := scene.CreateEntity(nil) + Expect(entered).To(ConsistOf(id)) + }) + + Specify("does not fire again when unrelated component is added", func() { + id := scene.CreateEntity(nil) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 30}) + }) + Expect(entered).To(BeEmpty()) + }) + + Specify("fires again when entity re-loses the excluded component", func() { + id := scene.CreateEntity(nil) + entered = nil + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(entered).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + }) + Expect(entered).To(ConsistOf(id)) + }) + }) + }) + + When("subscribing to entity exit events", func() { + var exited []ecs.ID + + BeforeEach(func() { + exited = nil + }) + + When("condition is HasComponent", func() { + BeforeEach(func() { + scene.SubscribeExit(ecs.HasComponent(positionType), func(id ecs.ID) { + exited = append(exited, id) + }) + }) + + Specify("does not fire when entity without the component is created", func() { + scene.CreateEntity(nil) + Expect(exited).To(BeEmpty()) + }) + + Specify("fires when entity loses the required component", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + }) + Expect(exited).To(ConsistOf(id)) + }) + + Specify("fires when entity with the component is deleted", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.DeleteEntity(id) + Expect(exited).To(ConsistOf(id)) + }) + + Specify("does not fire when entity without the component is deleted", func() { + id := scene.CreateEntity(nil) + scene.DeleteEntity(id) + Expect(exited).To(BeEmpty()) + }) + + Specify("does not fire when an unrelated component is removed", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.AddComponent(op, ageType, Age{Value: 30}) + }) + exited = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, ageType) + }) + Expect(exited).To(BeEmpty()) + }) + + Specify("stops firing after subscription is deleted", func() { + sub := scene.SubscribeExit(ecs.HasComponent(ageType), func(id ecs.ID) { + exited = append(exited, id) + }) + sub.Delete() + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 25}) + }) + scene.DeleteEntity(id) + Expect(exited).To(BeEmpty()) + }) + + Specify("does not fire when a component is replaced in place", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.ReplaceComponent(op, positionType, Position{X: 3, Y: 4}) + }) + Expect(exited).To(BeEmpty()) + }) + + Specify("does not fire when a component is removed and re-added in the same edit", func() { + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + }) + Expect(exited).To(BeEmpty()) + }) + }) + + When("condition is LacksComponent", func() { + BeforeEach(func() { + scene.SubscribeExit(ecs.LacksComponent(positionType), func(id ecs.ID) { + exited = append(exited, id) + }) + }) + + Specify("fires when entity gains the excluded component", func() { + id := scene.CreateEntity(nil) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(ConsistOf(id)) + }) + + Specify("does not fire when a component-less entity is deleted", func() { + // LacksComponent(pos) is satisfied by the empty archetype, and deletion + // dispatches exit with EmptyTypeMask as the "new" mask — so the condition + // remains satisfied and exit does not fire. + id := scene.CreateEntity(nil) + Expect(exited).To(BeEmpty()) + scene.DeleteEntity(id) + Expect(exited).To(BeEmpty()) + }) + }) + }) + + When("using Freeze and Unfreeze", func() { + var id ecs.ID + + BeforeEach(func() { + id = scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + }) + }) + + Specify("component pointer obtained via ReadEntity remains valid while frozen", func() { + var pos *Position + scene.Freeze() + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + }) + // AddComponent would normally move the entity to a new archetype, + // invalidating pos, but Freeze keeps the mutation buffered. + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + }) + Expect(*pos).To(Equal(Position{X: 1, Y: 2})) + scene.Unfreeze() + }) + + Specify("structural mutations are deferred while frozen and committed on Unfreeze", func() { + scene.Freeze() + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + }) + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) + + scene.Unfreeze() + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeTrue()) + }) + + Specify("DeleteEntity is deferred while frozen and applied on Unfreeze", func() { + scene.Freeze() + scene.DeleteEntity(id) + Expect(scene.HasEntity(id)).To(BeTrue()) + + scene.Unfreeze() + Expect(scene.HasEntity(id)).To(BeFalse()) + }) + + Specify("enter subscriptions fire after Unfreeze, not during mutation", func() { + var entered []ecs.ID + scene.SubscribeEnter(ecs.HasComponent(ageType), func(id ecs.ID) { + entered = append(entered, id) + }) + + scene.Freeze() + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + }) + Expect(entered).To(BeEmpty()) + + scene.Unfreeze() + Expect(entered).To(ConsistOf(id)) + }) + + Specify("exit subscriptions fire after Unfreeze, not during mutation", func() { + var exited []ecs.ID + scene.SubscribeExit(ecs.HasComponent(positionType), func(id ecs.ID) { + exited = append(exited, id) + }) + + scene.Freeze() + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.RemoveComponent(op, positionType) + }) + Expect(exited).To(BeEmpty()) + + scene.Unfreeze() + Expect(exited).To(ConsistOf(id)) + }) + + Specify("nested Freeze calls require a matching number of Unfreeze calls before mutations commit", func() { + scene.Freeze() + scene.Freeze() + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + }) + + scene.Unfreeze() + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) + + scene.Unfreeze() + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeTrue()) + }) + + Specify("Unfreeze panics if called without a matching Freeze", func() { + Expect(func() { scene.Unfreeze() }).To(Panic()) + }) + + When("CreateEntity is called while frozen", func() { + var frozenID ecs.ID + + BeforeEach(func() { + scene.Freeze() + frozenID = scene.CreateEntity(func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 7, Y: 8}) + }) + }) + + Specify("HasEntity returns true for the deferred entity", func() { + Expect(scene.HasEntity(frozenID)).To(BeTrue()) + }) + + Specify("CheckEntity panics for the deferred entity", func() { + Expect(func() { + scene.CheckEntity(frozenID, ecs.HasComponent(positionType)) + }).To(Panic()) + }) + + Specify("ReadEntity panics for the deferred entity", func() { + Expect(func() { + scene.ReadEntity(frozenID, func(_ *ecs.ReadOperation) {}) + }).To(Panic()) + }) + + Specify("entity is fully committed after Unfreeze", func() { + scene.Unfreeze() + Expect(scene.CheckEntity(frozenID, ecs.HasComponent(positionType))).To(BeTrue()) + var pos *Position + scene.ReadEntity(frozenID, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + }) + Expect(*pos).To(Equal(Position{X: 7, Y: 8})) + }) + }) + }) + + When("performing mutations from within notification handlers", func() { + Specify("entity created in enter notification is accessible after the triggering operation", func() { + var createdID ecs.ID + scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { + createdID = scene.CreateEntity(nil) + }) + + id := scene.CreateEntity(nil) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + + Expect(createdID).ToNot(Equal(ecs.NilID)) + Expect(scene.HasEntity(createdID)).To(BeTrue()) + }) + + Specify("entity created in notification fires its own enter notifications", func() { + var lacksPositionEntered []ecs.ID + scene.SubscribeEnter(ecs.LacksComponent(positionType), func(id ecs.ID) { + lacksPositionEntered = append(lacksPositionEntered, id) + }) + + var createdID ecs.ID + scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { + createdID = scene.CreateEntity(nil) + }) + + triggerID := scene.CreateEntity(nil) + lacksPositionEntered = nil // reset: clear notification from triggerID's own creation + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + + // createdID is created (deferred) after HasPosition enter fires; + // its own LacksPosition enter should fire in the same processQueue run. + Expect(lacksPositionEntered).To(ConsistOf(createdID)) + }) + + Specify("entity edited in enter notification is updated after the triggering operation", func() { + targetID := scene.CreateEntity(nil) + + scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { + scene.EditEntity(targetID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 42}) + }) + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + + Expect(scene.CheckEntity(targetID, ecs.HasComponent(ageType))).To(BeTrue()) + }) + + Specify("edit in notification fires its own enter notifications", func() { + var ageEntered []ecs.ID + scene.SubscribeEnter(ecs.HasComponent(ageType), func(id ecs.ID) { + ageEntered = append(ageEntered, id) + }) + + targetID := scene.CreateEntity(nil) + + scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { + scene.EditEntity(targetID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 99}) + }) + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + + Expect(ageEntered).To(ConsistOf(targetID)) + }) + + Specify("entity deleted in exit notification is removed after the triggering operation", func() { + sideEffectID := scene.CreateEntity(nil) + scene.EditEntity(sideEffectID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + }) + + scene.SubscribeExit(ecs.HasComponent(positionType), func(id ecs.ID) { + if id != sideEffectID { + scene.DeleteEntity(sideEffectID) + } + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + }) + scene.DeleteEntity(triggerID) + + Expect(scene.HasEntity(sideEffectID)).To(BeFalse()) + }) + + Specify("delete in notification fires its own exit notifications", func() { + var posExited []ecs.ID + scene.SubscribeExit(ecs.HasComponent(positionType), func(id ecs.ID) { + posExited = append(posExited, id) + }) + + targetID := scene.CreateEntity(nil) + scene.EditEntity(targetID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + }) + posExited = nil // reset + + scene.SubscribeEnter(ecs.HasComponent(ageType), func(_ ecs.ID) { + scene.DeleteEntity(targetID) + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.AddComponent(op, ageType, Age{Value: 10}) + }) + + Expect(posExited).To(ConsistOf(targetID)) + }) + }) + +}) diff --git a/game/ecs/subscription.go b/game/ecs/subscription.go deleted file mode 100644 index f42914f5..00000000 --- a/game/ecs/subscription.go +++ /dev/null @@ -1,9 +0,0 @@ -package ecs - -import "github.com/mokiat/lacking/util/observer" - -// DeleteCallback is a mechanism to receive deletion notifications. -type DeleteCallback func(EntityID) - -// DeleteSubscription represents a notification subscription for deletions. -type DeleteSubscription = observer.Subscription[DeleteCallback] diff --git a/game/ecs/suite_test.go b/game/ecs/suite_test.go new file mode 100644 index 00000000..c77629f4 --- /dev/null +++ b/game/ecs/suite_test.go @@ -0,0 +1,13 @@ +package ecs_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestECS(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ECS Suite") +} diff --git a/game/engine.go b/game/engine.go index 99e1eb2f..23512d46 100644 --- a/game/engine.go +++ b/game/engine.go @@ -3,7 +3,6 @@ package game import ( "time" - "github.com/mokiat/lacking/game/ecs" "github.com/mokiat/lacking/game/graphics" "github.com/mokiat/lacking/game/physics" "github.com/mokiat/lacking/render" @@ -43,12 +42,6 @@ func WithGraphics(gfxEngine *graphics.Engine) EngineOption { } } -func WithECS(ecsEngine *ecs.Engine) EngineOption { - return func(e *Engine) { - e.ecsEngine = ecsEngine - } -} - func NewEngine(opts ...EngineOption) *Engine { result := &Engine{ lastTick: time.Now(), @@ -67,7 +60,6 @@ type Engine struct { gfxWorker Worker physicsEngine *physics.Engine gfxEngine *graphics.Engine - ecsEngine *ecs.Engine registry *resourceRegistry @@ -105,10 +97,6 @@ func (e *Engine) Graphics() *graphics.Engine { return e.gfxEngine } -func (e *Engine) ECS() *ecs.Engine { - return e.ecsEngine -} - func (e *Engine) ActiveScene() *Scene { return e.activeScene } diff --git a/game/scene.go b/game/scene.go index 40dc00a1..3a238ca9 100644 --- a/game/scene.go +++ b/game/scene.go @@ -15,6 +15,9 @@ import ( "github.com/mokiat/lacking/render" ) +// ECSScope is the default scope used for all ECS scenes in the game. +var ECSScope = ecs.NewScope() + // SceneInfo specifies details regarding the scene to be created. type SceneInfo struct { @@ -33,6 +36,11 @@ type SceneInfo struct { // Defaults to `true`. IncludeGraphics opt.T[bool] + // ECSScope specifies the scope to be used for the ECS sub-scene of the scene. + // + // Defaults to the global `ECSScope`. + ECSScope opt.T[*ecs.Scope] + // FixedTimestep determines the duration of a single fixed-step tick. FixedTimestep opt.T[time.Duration] } @@ -51,8 +59,8 @@ func newScene(engine *Engine, info SceneInfo) *Scene { } var ecsScene *ecs.Scene - if ecsEngine := engine.ECS(); ecsEngine != nil && includeECS { - ecsScene = ecsEngine.CreateScene() + if includeECS { + ecsScene = ecs.NewScene(info.ECSScope.ValueOrDefault(ECSScope)) } var physicsScene *physics.Scene @@ -350,10 +358,6 @@ func (s *Scene) doFixedUpdate(elapsedTime time.Duration) { nodeSpan = metric.BeginRegion("node-target") s.hierarchyScene.ApplyNodesToTargets() nodeSpan.End() - - if s.ecsScene != nil { - s.ecsScene.Purge() - } } func (s *Scene) doInterpolationUpdate(fraction float64) { @@ -375,10 +379,6 @@ func (s *Scene) doUpdate(elapsedTime time.Duration) { }) callbackSpan.End() - if s.ecsScene != nil { - s.ecsScene.Purge() - } - if s.gfxScene != nil { s.gfxScene.Update(elapsedTime) } diff --git a/mkdocs.yml b/mkdocs.yml index 2e8c8c3e..8d71269c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,34 @@ site_url: http://mokiat.com/lacking repo_url: https://github.com/mokiat/lacking/ theme: name: material +nav: + - Home: index.md + - Getting Started: getting-started.md + - Examples: examples.md + - Manual: + - Application: manual/application/index.md + - ECS: manual/ecs/index.md + - Game: manual/game/index.md + - Graphics: + - Shader: manual/graphics/shader.md + - Rendering: manual/rendering/index.md + - User Interface: manual/user-interface/index.md + - Development: + - Physics: + - Overview: development/physics/index.md + - References: development/physics/references.md + - Theory: + - Principles: development/physics/theory/principles.md + - Terminology: development/physics/theory/terminology.md + - Equations: development/physics/theory/equations.md + - Effective Mass: development/physics/theory/effective-mass.md + - Moment of Inertia: development/physics/theory/moment-of-intertia.md + - Derivations: + - Impulse: development/physics/derivations/impulse-derivation.md + - Effective Mass: development/physics/derivations/effective-mass-derivation.md + - Math: + - Vectors: development/math/vectors.md + - Derivatives: development/math/derivatives.md markdown_extensions: - pymdownx.arithmatex: generic: true From 25ca89610ceb450ce2cddb5cd650074aa3403c9e Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 19:29:33 +0300 Subject: [PATCH 21/59] docs: fix issues with derivatives table --- docs/development/math/derivatives.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development/math/derivatives.md b/docs/development/math/derivatives.md index 6e28ebd7..3f64b40b 100644 --- a/docs/development/math/derivatives.md +++ b/docs/development/math/derivatives.md @@ -9,11 +9,11 @@ The following is a table of first order derivatives. | Scaled variable | $(cx)'$ | $cx'$ | | Sum | $(x+y)'$ | $x' + y'$ | | Product | $(xy)'$ | $xy'+x'y$ | -| Quot | $\frac{x}{y}$ | $\frac{x.y'+x'.y}{y^2}$ | -| Reciprocal | $\frac{1}{x}$ | $\frac{-x'}{x^2}$ | +| Quotient | $(\frac{x}{y})'$ | $\frac{x'.y-x.y'}{y^2}$ | +| Reciprocal | $(\frac{1}{x})'$ | $\frac{-x'}{x^2}$ | | Power | $(x^y)'$ | $yx^{y-1}$ | | Square root | $\sqrt{x}'$ | $\frac{1}{2\sqrt{x}}$ | | Chained rule | $\frac{\partial f(g(x))}{\partial x}$ | $\frac{\partial f(g(x))}{\partial g(x)} \frac{\partial g(x)}{\partial x}$ | -| Multivariable rule | $\frac{\partial f(u(x), v(x))}{\partial x}$ | $\frac{\partial f(u(x), v(x))}{\partial u(x)}\frac{\partial f(u(x), v(x))}{\partial u(x)} + \frac{\partial f(u(x), v(x))}{\partial x}$ | +| Multivariable rule | $\frac{\partial f(u(x), v(x))}{\partial x}$ | $\frac{\partial f(u(x), v(x))}{\partial u(x)}\frac{\partial u(x)}{\partial x} + \frac{\partial f(u(x), v(x))}{\partial v(x)}\frac{\partial v(x)}{\partial x}$ | | Vector square length | $(\vec{v}.\vec{v})'$ | $2\vec{v}\vec{v}'$ | | Vector length | $\|\vec{v}\|'$ | $\hat{v}\vec{v}'$ | From a6fd1909cc4da9d1a5a01591ddfe67b9facedc5c Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 19:38:58 +0300 Subject: [PATCH 22/59] docs: fix operator rendering issues --- docs/manual/graphics/shader.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/manual/graphics/shader.md b/docs/manual/graphics/shader.md index 3a406046..730c9698 100644 --- a/docs/manual/graphics/shader.md +++ b/docs/manual/graphics/shader.md @@ -43,7 +43,7 @@ Following is a list of assignment operators that can be used to assign a value t | `<<=` | Shifts the variable to the left by the value number of bits. | | `>>=` | Shifts the variable to the right by the value number of bits. | | `&=` | Assigns the bitwise AND operation on the variable and the value to the variable. | -| `│=` | Assigns the bitwise OR operation on the variable and the value to the variable. | +| |= | Assigns the bitwise OR operation on the variable and the value to the variable. | | `^=` | Assigns the bitwise XOR operation on the variable and the value to the variable. | ### Unary Operators @@ -77,20 +77,20 @@ The following binary operators can be used inside expressions to combine two sub | `<=` | Returns a boolean value indicating whether the first expression is smaller than or equal to the second expression. Both sides need to be expressions of the same type and be ordered. | | `>=` | Returns a boolean value indicating whether the second expression is smaller than or equal to the first expression. Both sides need to be expressions of the same type and be ordered. | | `&` | Returns the result of a bitwise AND operation on the two expressions. Both sides need to be integer expressions of the same type. | -| `│` | Returns the result of a bitwise OR operation on the two expressions. Both sides need to be integer expressions of the same type. | +| | | Returns the result of a bitwise OR operation on the two expressions. Both sides need to be integer expressions of the same type. | | `^` | Returns the result of a bitwise XOR operation on the two expressions. Both sides need to be integer expressions of the same type. | | `&&` | Returns the result of a logical AND operation on the two expressions. Both sides need to be boolean expressions of the same type. | -| `││` | Returns the result of a logical OR operation on the two expressions. Both sides need to be boolean expressions of the same type. | +| || | Returns the result of a logical OR operation on the two expressions. Both sides need to be boolean expressions of the same type. | The operator precedence is similar to the official Go one and is described in the following table (higher is applied first). | Precedence | Operator | | ---------- | -------- | | 5 | `*`, `/`, `%`, `<<`, `>>`, `&` | -| 4 | `+`, `-`, `│`, `^` | +| 4 | `+`, `-`, |, `^` | | 3 | `==`, `!=`, `<`, `<=`, `>`, `>=` | | 2 | `&&` | -| 1 | `││` | +| 1 | || | Operators with the same precedence associate from left to right (i.e. the operators are applied from left to right). From 505dfb4b32f8c801e58f3e0a7439935d6f38d314 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 20:10:48 +0300 Subject: [PATCH 23/59] docs: fix typos --- docs/development/math/vectors.md | 2 +- docs/development/physics/index.md | 2 +- docs/development/physics/references.md | 2 +- docs/development/physics/theory/equations.md | 4 ++-- .../{moment-of-intertia.md => moment-of-inertia.md} | 6 +++--- docs/development/physics/theory/principles.md | 8 ++++---- docs/index.md | 2 +- docs/manual/application/index.md | 8 ++++---- docs/manual/graphics/shader.md | 2 +- mkdocs.yml | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) rename docs/development/physics/theory/{moment-of-intertia.md => moment-of-inertia.md} (98%) diff --git a/docs/development/math/vectors.md b/docs/development/math/vectors.md index 40dceed5..510fdae9 100644 --- a/docs/development/math/vectors.md +++ b/docs/development/math/vectors.md @@ -14,7 +14,7 @@ $$ \vec{a} \cdot \vec{b} = |a||b|\cos{\alpha} $$ -The dot product is very useful in determining if two vectors are perpendicular or whether they point in the same direction. A value of $0$ indicates that they are perpendicular. A positive value indicates that they point in the same half-space direction. A negataive value indicates that they point in opposite half-space directions. +The dot product is very useful in determining if two vectors are perpendicular or whether they point in the same direction. A value of $0$ indicates that they are perpendicular. A positive value indicates that they point in the same half-space direction. A negative value indicates that they point in opposite half-space directions. Furthermore, if one of the vectors is unit (has length of one), it returns the length of the other vector's component that is along the first vector's direction. This can be used to measure the distance of a point to a plane if we have the normal of the plane and an arbitrary point on it. diff --git a/docs/development/physics/index.md b/docs/development/physics/index.md index a067d03c..dc8dd0f7 100644 --- a/docs/development/physics/index.md +++ b/docs/development/physics/index.md @@ -35,7 +35,7 @@ More broadly, it performs the following sequence of steps. 1. Applies forces to all dynamic objects. 1. Derives the new velocities of all dynamic objects based on the accumulated accelerations. 1. Applies correction impulses to all dynamic objects that have constraints on them. -1. Derives the new positions of all dynamic objects based on the evaluated velocitiess +1. Derives the new positions of all dynamic objects based on the evaluated velocities 1. Applies correction nudges to all dynamic objects that have constraints on them. 1. Detects collisions and creates temporary collision constraints. diff --git a/docs/development/physics/references.md b/docs/development/physics/references.md index cc2d7ecd..74a24a08 100644 --- a/docs/development/physics/references.md +++ b/docs/development/physics/references.md @@ -2,7 +2,7 @@ The following articles and videos are a good place to start. -## Moment of Intertia +## Moment of Inertia - [What is a tensor](https://www.physlink.com/education/askexperts/ae168.cfm) - [Inertia Tensor](http://www.kwon3d.com/theory/moi/iten.html) diff --git a/docs/development/physics/theory/equations.md b/docs/development/physics/theory/equations.md index f38f8ca1..62ab8e60 100644 --- a/docs/development/physics/theory/equations.md +++ b/docs/development/physics/theory/equations.md @@ -4,9 +4,9 @@ | -------- | ----------- | ----- | | $F = ma$ | $\vec{F} = M \vec{a}$ | The force is collinear to the acceleration and is proportional to the mass. | | $p = mv$ | $\vec{p} = M \vec{v}$ | The momentum is collinear to the velocity and is proportional to the mass. | -| $\tau = I \alpha$ | $\vec{\tau} = I \vec{\alpha} + \vec{\omega} \times I \vec{\omega}$ | The vector form in 3D is the more accurate representation. It takes into account the fact that there can be a torque even without an angular acceleration, just because of the shape of the object. Check [Moment of Intertia](./moment-of-intertia.md) for more information. The torque might not be collinear with the angular acceleration. | +| $\tau = I \alpha$ | $\vec{\tau} = I \vec{\alpha} + \vec{\omega} \times I \vec{\omega}$ | The vector form in 3D is the more accurate representation. It takes into account the fact that there can be a torque even without an angular acceleration, just because of the shape of the object. Check [Moment of Inertia](./moment-of-inertia.md) for more information. The torque might not be collinear with the angular acceleration. | | $\tau = rF$ | $\vec{\tau} = \vec{r} \times \vec{F}$ | The cross product handles situations where the radius is not perpendicular to the force. | -| $L = I\omega$ | $\vec{L} = I \vec{\omega}$ | The angular momentum need not be collinear with the angular velocity. Check [Moment of Intertia](./moment-of-intertia.md) for more information. | +| $L = I\omega$ | $\vec{L} = I \vec{\omega}$ | The angular momentum need not be collinear with the angular velocity. Check [Moment of Inertia](./moment-of-inertia.md) for more information. | | $v_t = \omega r$ | $\vec{v_t} = \vec{\omega} \times \vec{r}$ | The cross product handles situations where the radius is not perpendicular to the angular velocity. The resulting tangential velocity, when not zero, is perpendicular to the angular velocity. | | $a_t = \alpha r$ | $\vec{a_t} = \vec{\alpha} \times \vec{r}$ | The cross product handles situations where the radius is not perpendicular to the angular acceleration. | | $F = \mu F_n$ | $\vec{F_{max}} = - \mu \hat{v_{t}} \|\vec{F_n}\|$ | This returns the maximum friction force. The actual force could be less if it would be sufficient to keep the object from moving. | diff --git a/docs/development/physics/theory/moment-of-intertia.md b/docs/development/physics/theory/moment-of-inertia.md similarity index 98% rename from docs/development/physics/theory/moment-of-intertia.md rename to docs/development/physics/theory/moment-of-inertia.md index 2c624399..40e1383c 100644 --- a/docs/development/physics/theory/moment-of-intertia.md +++ b/docs/development/physics/theory/moment-of-inertia.md @@ -1,4 +1,4 @@ -# Moment of Intertia +# Moment of Inertia While mass represents the reluctance of an object to change its linear velocity, moment of inertia represents the reluctance of an object to change its angular momentum. However, while having a lot of things in common, mass is much easier to reason about, whereas moment of inertia induces some strange properties on the motion of an object. @@ -46,7 +46,7 @@ A key thing to note here is that unlike the force equation, where the mass is a > NOTE: This last bit was hard for me to understand or create a mental image of. In the following text I will try to create a mostly intuitive explanation as to why the equation for torque is so complicated. -## The Intertia Tensor +## The Inertia Tensor We should first explore the Inertia Tensor $I$. As mentioned, it is a 3x3 matrix that is defined as follows. @@ -127,7 +127,7 @@ We have an object comprised of two particles ($p_1$ and $p_2$), connected by a z Before diving into the moment inertia tensor and the equations from above, let us try to use high-school physics to evaluate what forces will act on the particles and if there is any torque actually induced. -If we look at point $p_1$, we can see that it is spinning with tangental velocity magninute of $x \omega$ (again, using high-school physics/math), which in our case is $2 \omega$. +If we look at point $p_1$, we can see that it is spinning with tangential velocity magnitude of $x \omega$ (again, using high-school physics/math), which in our case is $2 \omega$. Since there are no external forces (there is no angular acceleration on the object), the only force that the particle experiences is the one by the rod that is keeping it attached to the object. The opposite (but equal) force is actually the fictitious [Centrifugal force](https://en.wikipedia.org/wiki/Centrifugal_force). diff --git a/docs/development/physics/theory/principles.md b/docs/development/physics/theory/principles.md index 96940717..8ddbfb3b 100644 --- a/docs/development/physics/theory/principles.md +++ b/docs/development/physics/theory/principles.md @@ -2,7 +2,7 @@ Following are some physics principles that are useful to keep in mind. -## Tangental velocity +## Tangential velocity The velocity that a point $p$ on an object experiences is equal to the sum of the velocity of the object and the angular velocity times the radius. @@ -10,9 +10,9 @@ $$ v_t = v + \omega r $$ -## Tangental Acceleration +## Tangential Acceleration -From the tangental velocity, we can derive the tangental acceleration of an offset point to be as follows: +From the tangential velocity, we can derive the tangential acceleration of an offset point to be as follows: $$ a_t = a + \alpha r @@ -26,7 +26,7 @@ This is probably not so unknown nowadays, as drones demonstrate this principle v ## Offset force -If a force is applied to an object at a point away from the center of mass, both a force at the center of mass and a torque are applied to the object. What is interesting here is that the magnitute of the force is the same as would be if the force were applied at the center of mass. +If a force is applied to an object at a point away from the center of mass, both a force at the center of mass and a torque are applied to the object. What is interesting here is that the magnitude of the force is the same as would be if the force were applied at the center of mass. That is, given an object with a center of mass $\vec{p_{cm}}$ and a force $\vec{F}$ that is applied at point $\vec{p}$, the resulting force and torque arise. diff --git a/docs/index.md b/docs/index.md index 21b26972..dc7d7238 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ ![Logo](images/logo.png) -Lacking is a game engine, or rather framework, written in Go. Unlike other engines that have an Editor UI through which a game is developed in a scripting language, with lacking one makes use of Go's lightning fast compilation times to develop in Go directly with their faviourite IDE and tools. +Lacking is a game engine, or rather framework, written in Go. Unlike other engines that have an Editor UI through which a game is developed in a scripting language, with lacking one makes use of Go's lightning fast compilation times to develop in Go directly with their favourite IDE and tools. What lacking provides is mechanisms to parse images and 3D model files, to load and render them, and apply physics and another effects on top. All of this is done through DSL or API calls. diff --git a/docs/manual/application/index.md b/docs/manual/application/index.md index 664ff55c..06c4d02a 100644 --- a/docs/manual/application/index.md +++ b/docs/manual/application/index.md @@ -4,16 +4,16 @@ title: Overview # Application -The Lacking game engine provides a lightweight API for managing an application window. In many regards, it is similar to what GLFW or SDL2 provide. The benefits are that it is a more Go-oriented and simplified API that is abstract enough to support Web builds as well. The downside is that it does not include all of the advanced features that the forementioned libraries provide. +The Lacking game engine provides a lightweight API for managing an application window. In many regards, it is similar to what GLFW or SDL2 provide. The benefits are that it is a more Go-oriented and simplified API that is abstract enough to support Web builds as well. The downside is that it does not include all of the advanced features that the aforementioned libraries provide. The API is described in the [api](https://pkg.go.dev/github.com/mokiat/lacking/app) package of the library. This package only includes an interface. To actually instantiate a working window, one must use one of the two available implementations: - The similarly named [app](https://pkg.go.dev/github.com/mokiat/lacking-native/app) package in [lacking-native](https://github.com/mokiat/lacking-native) provides a Desktop implementation (at the time of writing based on GLFW). - The similarly named [app](https://pkg.go.dev/github.com/mokiat/lacking-js/app) package in [lacking-js](https://github.com/mokiat/lacking-js) provides a Web implementation. -> By having the an abstract API, one benefit is that the underlying implementation can be swapped. In fact, there were successful ports to the API to SDL2 but due to complexities in building so and dll files, it was rolled back in favour of GLFW. +> By having an abstract API, one benefit is that the underlying implementation can be swapped. In fact, there were successful ports to the API to SDL2 but due to complexities in building so and dll files, it was rolled back in favour of GLFW. -In it's core, a developer need only implement the `Controller` interface, in order to get keyboard, mouse, etc. events and be able to make changes to the window object itself. +In its core, a developer need only implement the `Controller` interface, in order to get keyboard, mouse, etc. events and be able to make changes to the window object itself. Following is a simple code for starting an app: @@ -43,6 +43,6 @@ type CustomController struct { ![Window](./images/example-window.png) -The `CustomController` in this cas eis the implementation of the `Controller` interface. It composes the `app.NopController` type, which provides no-op implementations for all methods of the interface. This allows one to override only relevant methods. +The `CustomController` in this case is the implementation of the `Controller` interface. It composes the `app.NopController` type, which provides no-op implementations for all methods of the interface. This allows one to override only relevant methods. In a usual project, the built-in `game.Controller` and `ui.Controller` implementations would be used instead and developers would then use higher-order APIs. diff --git a/docs/manual/graphics/shader.md b/docs/manual/graphics/shader.md index 730c9698..21826585 100644 --- a/docs/manual/graphics/shader.md +++ b/docs/manual/graphics/shader.md @@ -209,7 +209,7 @@ The following table lists constructor built-in functions. | `uint(v float) uint` | unbounded | Converts a float into an unsigned integer. | | `float(v bool) float` | unbounded | Converts a boolean into a float. | | `float(v int) float` | unbounded | Converts an integer into a float. | -| `float(v uint) float` | unbounded | Converst an unsigned integer into a float. | +| `float(v uint) float` | unbounded | Converts an unsigned integer into a float. | | `vec2(v float) vec2` | unbounded | Returns a `vec2` with all components equal to the value `v`. | | `vec2(x, y float) vec2` | unbounded | Returns a `vec2` with the components set to `x` and `y` respectively. | | `vec3(v float) vec3` | unbounded | Returns a `vec3` with all components equal to the value `v`. | diff --git a/mkdocs.yml b/mkdocs.yml index 8d71269c..391c0c2d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,7 @@ nav: - Terminology: development/physics/theory/terminology.md - Equations: development/physics/theory/equations.md - Effective Mass: development/physics/theory/effective-mass.md - - Moment of Inertia: development/physics/theory/moment-of-intertia.md + - Moment of Inertia: development/physics/theory/moment-of-inertia.md - Derivations: - Impulse: development/physics/derivations/impulse-derivation.md - Effective Mass: development/physics/derivations/effective-mass-derivation.md From 7399faddb4e019ecfc834bf2d53cc591194a58c5 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 20:35:25 +0300 Subject: [PATCH 24/59] docs: Structure and content improvements --- docs/examples.md | 18 +++++++++--------- docs/getting-started.md | 21 +++++++++------------ docs/index.md | 4 ++-- mkdocs.yml | 7 ++++--- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 92fb01b3..832fa72c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -4,29 +4,29 @@ title: Examples # Examples -Following are some example games and apps made with Lacking. +Games and apps built with Lacking: -### Rally MKA +## Rally MKA Drive around in a car with no particular purpose except to zone out. Best played with keyboard or mouse. The gamepad option is hard. [![Rally MKA](./images/example-rally-mka.png)](https://mokiat.itch.io/rally-mka) -> This game was the initial reason for the lacking game engine. +> This game was the initial reason for the Lacking game engine. -### AI Suppression +## AI Suppression -A Game Jam entry. Use the keyboard to defend your ship from alien airships. Users of vim will have an easy time here. +A Game Jam entry. Defend your ship from alien airships using the keyboard — vim users will feel right at home. [![AI Suppression](./images/example-ai-suppression.png)](https://mokiat.itch.io/ai-suppression) -> A solo 48h Sofia Game Jam (2023) entry. +> Solo 48h Sofia Game Jam (2023) entry. -### Dem Cows +## Dem Cows -Fly around in a plane and use a hanging club to pop cow balloons. As it uses semi-realistic physics it is best played with a gamepad and care should be taken regarding stall and speed. There wasn't enough time to balance this game. Winning it with keyboard is nearly impossible. +Fly around in a plane and use a hanging club to pop cow balloons. The physics are semi-realistic, so stall and speed management matter. Best played with a gamepad — winning with a keyboard is very difficult. [![Dem Cows](./images/example-dem-cows.png)](https://mokiat.itch.io/dem-cows) -> A duo 48h Hardcore Game Jam (2024) entry. +> Duo 48h Hardcore Game Jam (2024) entry. diff --git a/docs/getting-started.md b/docs/getting-started.md index 37c264dd..c2bd8938 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,48 +4,45 @@ title: Getting Started # Getting Started -The easiest way to set up a new project is to use the `template` package. Beforehand, make sure you have the necessary prerequisites. +The easiest way to set up a new project is to use the `template` package. First, make sure you have the necessary prerequisites. * Ensure the [Go glfw bindings](https://github.com/go-gl/glfw?tab=readme-ov-file#installation) run on your platform. * Ensure you have the [gonew](https://go.dev/blog/gonew) tool. - ``` + ```sh go install golang.org/x/tools/cmd/gonew@latest ``` * Ensure you have the [task](https://taskfile.dev/) tool. - ``` + ```sh go install github.com/go-task/task/v3/cmd/task@latest ``` Afterwards, you can use the following steps: -1. Create new project using the Lacking template. +1. Create a new project using the Lacking template. - ``` + ```sh gonew github.com/mokiat/lacking-template@latest example.com/your/namespace projectdir - ``` - - ``` cd projectdir ``` 1. Build and package the assets. - ``` + ```sh task pack ``` + You would only need to run `task pack` initially and whenever the source resources (images, models) or the pipeline that transforms them have been changed. + 1. Run the project. - ``` + ```sh task run ``` -You would only need to run `task pack` initially and whenever the source resources (images, models) or the pipeline that transforms them have been changed. - You can check for available task commands as follows: ```sh diff --git a/docs/index.md b/docs/index.md index dc7d7238..20f548a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,9 +8,9 @@ What lacking provides is mechanisms to parse images and 3D model files, to load While there is something like an Editor, its purpose is currently for previewing a scene only. -**WARNING** The [lacking](https://github.com/mokiat/lacking) repository is a solo hobby project of mine and is still in the prototyping phase. I am iterating fast and making breaking changes. Use at your discretion. +**NOTE:** The [lacking](https://github.com/mokiat/lacking) repository is a solo hobby project of mine and is still in the prototyping phase. I am iterating fast and making breaking changes. Use at your discretion. ## Next steps -* Check the [Getting Started](./getting-started.md) page on how to set up your own Hello World project. +* See the [Getting Started](./getting-started.md) page on how to set up your own Hello World project. * Check the [Examples](./examples.md) page on games and apps that have been implemented using Lacking. diff --git a/mkdocs.yml b/mkdocs.yml index 391c0c2d..14eb6165 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,9 +5,9 @@ theme: name: material nav: - Home: index.md - - Getting Started: getting-started.md - Examples: examples.md - - Manual: + - Getting Started: getting-started.md + - User's Guide: - Application: manual/application/index.md - ECS: manual/ecs/index.md - Game: manual/game/index.md @@ -15,7 +15,7 @@ nav: - Shader: manual/graphics/shader.md - Rendering: manual/rendering/index.md - User Interface: manual/user-interface/index.md - - Development: + - Internals: - Physics: - Overview: development/physics/index.md - References: development/physics/references.md @@ -34,6 +34,7 @@ nav: markdown_extensions: - pymdownx.arithmatex: generic: true + - pymdownx.superfences: extra_javascript: - https://polyfill.io/v3/polyfill.min.js?features=es6 - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js From 8613166186705e88fb98d499bea10e039d5c208d Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 20:45:26 +0300 Subject: [PATCH 25/59] docs: Further improvements --- docs/manual/application/index.md | 12 ++++++------ docs/manual/user-interface/index.md | 30 +++++++++++++---------------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/manual/application/index.md b/docs/manual/application/index.md index 06c4d02a..e5664a53 100644 --- a/docs/manual/application/index.md +++ b/docs/manual/application/index.md @@ -11,11 +11,11 @@ The API is described in the [api](https://pkg.go.dev/github.com/mokiat/lacking/a - The similarly named [app](https://pkg.go.dev/github.com/mokiat/lacking-native/app) package in [lacking-native](https://github.com/mokiat/lacking-native) provides a Desktop implementation (at the time of writing based on GLFW). - The similarly named [app](https://pkg.go.dev/github.com/mokiat/lacking-js/app) package in [lacking-js](https://github.com/mokiat/lacking-js) provides a Web implementation. -> By having an abstract API, one benefit is that the underlying implementation can be swapped. In fact, there were successful ports to the API to SDL2 but due to complexities in building so and dll files, it was rolled back in favour of GLFW. +> By having an abstract API, the underlying implementation can be swapped. There were successful ports to SDL2, but due to complexities in building `.so` and `.dll` files, it was rolled back in favour of GLFW. -In its core, a developer need only implement the `Controller` interface, in order to get keyboard, mouse, etc. events and be able to make changes to the window object itself. +At its core, a developer needs only to implement the `Controller` interface in order to receive keyboard, mouse, and gamepad events and make changes to the window object itself. -Following is a simple code for starting an app: +The following is a minimal example for starting an app: ```go package main @@ -41,8 +41,8 @@ type CustomController struct { } ``` -![Window](./images/example-window.png) +The `CustomController` above is the implementation of the `Controller` interface. It composes the `app.NopController` type, which provides no-op implementations for all methods of the interface. This allows one to override only the relevant methods. -The `CustomController` in this case is the implementation of the `Controller` interface. It composes the `app.NopController` type, which provides no-op implementations for all methods of the interface. This allows one to override only relevant methods. +![Window](./images/example-window.png) -In a usual project, the built-in `game.Controller` and `ui.Controller` implementations would be used instead and developers would then use higher-order APIs. +In a typical project, the built-in `game.Controller` and `ui.Controller` implementations would be used instead, with developers interacting through higher-level APIs. diff --git a/docs/manual/user-interface/index.md b/docs/manual/user-interface/index.md index efb07607..fff4bed4 100644 --- a/docs/manual/user-interface/index.md +++ b/docs/manual/user-interface/index.md @@ -4,15 +4,15 @@ title: Overview # User Interface -The user interface API of lacking is comprised of multiple layers that are more commonly seen in standard desktop or web UI, instead of gaming. This has some historical reasons but the idea was also to have something that can be used for app/tool development as well. +The user interface API of Lacking comprises multiple layers more commonly seen in standard desktop or web UI than in game engines. This makes it suitable for app and tool development, not just games. ## Element API The core layer of the API represents the user interface in a similar way to web pages. A window is comprised of a number of nested Elements that each can have custom rendering behavior and input event handling. -The way Elements are created is imperative, which can be more optimal and reduce memory usage but requires more boilerplate and manual work to coordinate, especially when certain UI elements need to disappear and/or be replaced with something else. +Element creation is imperative, which can be more efficient and reduce memory usage, but requires more boilerplate and manual coordination — especially when elements need to be dynamically added or removed. -Following is a rough idea how this can be used. +The following shows how this might be used. ```go // initUI function can be passed to the UI controller bootstrap function. @@ -30,7 +30,7 @@ func initUI(window *ui.Window) { } ``` -A container component can then be implemented as follows. +A container component can be implemented as follows. ```go import ( @@ -89,7 +89,7 @@ func (c *Container) OnRender(element *ui.Element, canvas *ui.Canvas) { } ``` -And a label can be implemented as follows. +A label can be implemented as follows. ```go import ( @@ -166,12 +166,11 @@ func (l *Label) updateIdealSize() { ## Component API -Using the Element API as shown above it is perfectly possible to construct a complete app UI but has some downsides especially when having to deal with dynamically adding and removing children. - -As such, the lacking framework includes a higher-level API that is declarative in nature. It is heavily inspired by frameworks like React, Vue, Svelte and similar. +While the Element API is sufficient for building a complete UI, it has downsides — particularly when elements need to be dynamically added or removed. -Building a UI page like in the Element example would look as follows. +As such, the Lacking framework includes a higher-level API that is declarative in nature. It is heavily inspired by frameworks like React, Vue, Svelte, and similar frameworks. +Rewriting the example above using the Component API would look as follows. ```go // initUI function can be passed to the UI controller bootstrap function. @@ -199,15 +198,14 @@ func (c *appComponent) Render() co.Instance { VerticalCenter: opt.V(0), }) co.WithData(LabelData{ - Font: co.OpenFont(c.Scope(), "ui:///roboto-bold.ttf"), - FontSize: opt.V(float32(24.0)), + Font: co.OpenFont(c.Scope(), "ui:///roboto-bold.ttf"), + FontSize: opt.V(float32(24.0)), FontColor: opt.V(ui.White()), - Text: "First Button", + Text: "Hello World", }) })) }) } - ``` A container component can be implemented as follows. @@ -225,10 +223,9 @@ type containerComponent struct { backgroundColor ui.Color } - func (c *containerComponent) OnUpsert() { data := co.GetData[ContainerData](c.Properties()) - c.layout = data.Layout + c.layout = data.Layout if data.BackgroundColor.Specified { c.backgroundColor = data.BackgroundColor.Value } else { @@ -247,7 +244,6 @@ func (c *containerComponent) Render() co.Instance { }) } - func (c *containerComponent) OnRender(element *ui.Element, canvas *ui.Canvas) { drawBounds := canvas.DrawBounds(element, false) if !c.backgroundColor.Transparent() { @@ -260,4 +256,4 @@ func (c *containerComponent) OnRender(element *ui.Element, canvas *ui.Canvas) { } ``` -The lacking framework includes a package [std](https://pkg.go.dev/github.com/mokiat/lacking/ui/std) (short for standard) that includes some basic component implementations. While not too pretty and unlikely to be used in a game, they can be useful when creating a tool or getting started with the component API. +The Lacking framework includes a package [std](https://pkg.go.dev/github.com/mokiat/lacking/ui/std) (short for standard) that includes some basic component implementations. While not too pretty and unlikely to be used in a game, they can be useful when creating a tool or getting started with the component API. From 8efde488183ee51aaaedaf8ad785668aec1aea3d Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 21:39:11 +0300 Subject: [PATCH 26/59] app: fix doc typos and inconsistencies --- app/gamepad.go | 10 +++++----- app/keyboard.go | 4 ++-- app/mouse.go | 2 +- app/platform.go | 2 +- app/window.go | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/gamepad.go b/app/gamepad.go index 998ec7e3..eba7a4d0 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -28,7 +28,7 @@ var ( type GamepadEvent struct { // Index indicates which gamepad triggered the event. By default - // the index for a the primary gamepad is 0. + // the index for the primary gamepad is 0. Index int // Gamepad is a reference to the gamepad that triggered the event. @@ -90,7 +90,7 @@ const ( // GamepadAction is used to specify the type of gamepad action that occurred. type GamepadAction int -// String returns a string representation of this event type, +// String returns a string representation of this event type. func (s GamepadAction) String() string { switch s { case GamepadActionNone: @@ -297,7 +297,7 @@ func (s GamepadStick) String() string { } } -// Gamepad represents a gamepad type joystick. Only input devides that can +// Gamepad represents a gamepad-type joystick. Only input devices that can // be mapped according to standard layout will work and have any axis // and button output. type Gamepad interface { @@ -341,7 +341,7 @@ type Gamepad interface { // RightStickX returns the horizontal axis of the right stick. RightStickX() float64 - // RightStickY returns the horizontal axis of the right stick. + // RightStickY returns the vertical axis of the right stick. RightStickY() float64 // RightStickButton returns the button represented by pressing @@ -372,7 +372,7 @@ type Gamepad interface { // DpadRightButton returns the right button of the left cluster. DpadRightButton() bool - // ActionTopButton returns the up button of the right cluster. + // ActionUpButton returns the up button of the right cluster. ActionUpButton() bool // ActionDownButton returns the down button of the right cluster. diff --git a/app/keyboard.go b/app/keyboard.go index 025c558e..a5806bb4 100644 --- a/app/keyboard.go +++ b/app/keyboard.go @@ -12,7 +12,7 @@ type KeyboardEvent struct { Code KeyCode // Character returns the character that was typed in case - // of an KeyboardActionType event. + // of a KeyboardActionType event. Character rune } @@ -47,7 +47,7 @@ const ( // KeyboardAction is used to specify the type of keyboard action that occurred. type KeyboardAction int -// String returns a string representation of this event type, +// String returns a string representation of this event type. func (a KeyboardAction) String() string { switch a { case KeyboardActionDown: diff --git a/app/mouse.go b/app/mouse.go index 04fdd59a..891d3b88 100644 --- a/app/mouse.go +++ b/app/mouse.go @@ -6,7 +6,7 @@ import "fmt" type MouseEvent struct { // Index indicates which mouse triggered the event. By default - // the index for a the primary mouse is 0. + // the index for the primary mouse is 0. // // This is applicable for devices with multiple pointers // (mobile) or in case a second mouse is emulated diff --git a/app/platform.go b/app/platform.go index 0122f073..198897df 100644 --- a/app/platform.go +++ b/app/platform.go @@ -22,7 +22,7 @@ const ( ) // OS represents the operating system that runs the application -// regarless if native or via an intermediary like a browser. +// regardless if native or via an intermediary like a browser. type OS string const ( diff --git a/app/window.go b/app/window.go index 824d069e..c11e5917 100644 --- a/app/window.go +++ b/app/window.go @@ -41,7 +41,7 @@ type Window interface { // This API supports up to 4 connected devices. Gamepads() [4]Gamepad - // Schedule queues a function to be called on the main thread + // Schedule queues a function to be called on the UI thread // when possible. There are no guarantees that it will necessarily // be on the next frame iteration. Schedule(fn func()) @@ -59,7 +59,7 @@ type Window interface { // CursorVisible returns whether a cursor is to be displayed on the // screen. This is determined based on the visibility and lock settings - // of the cursor + // of the cursor. CursorVisible() bool // SetCursorVisible changes whether a cursor is displayed on the From c9635dc2960520e2e8d475142a66981abdb3143d Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 21:42:14 +0300 Subject: [PATCH 27/59] app: add getter for cursor locked --- app/window.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/window.go b/app/window.go index c11e5917..04e37e56 100644 --- a/app/window.go +++ b/app/window.go @@ -66,6 +66,10 @@ type Window interface { // screen. SetCursorVisible(visible bool) + // CursorLocked returns whether the cursor is trapped within the + // boundaries of the window. + CursorLocked() bool + // SetCursorLocked traps the cursor within the boundaries of the window // and reports relative motion events. This method also hides the cursor. SetCursorLocked(locked bool) From 29991f4f7564d9af57be2dc12f09f95212af4f40 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 21:49:53 +0300 Subject: [PATCH 28/59] app: resolve more godoc issues --- app/gamepad.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/gamepad.go b/app/gamepad.go index eba7a4d0..5a95b9e3 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -9,6 +9,8 @@ var ( // GamepadMinEventInterval is the minimum interval between // gamepad event processing. This is to ensure that even if there are no // native events, the gamepad will still produce events. + // + // This value can be changed from the UI thread. GamepadMinEventInterval = 30 * time.Millisecond // GamepadRepeatDelay is the initial delay before a held down @@ -348,10 +350,10 @@ type Gamepad interface { // on the right stick. RightStickButton() bool - // LeftTrigger returns the left trigger button. + // LeftTrigger returns the analog value of the left trigger. LeftTrigger() float64 - // RightTrigger returns the right trigger button. + // RightTrigger returns the analog value of the right trigger. RightTrigger() float64 // LeftBumper returns the left bumper button. @@ -384,10 +386,10 @@ type Gamepad interface { // ActionRightButton returns the right button of the right cluster. ActionRightButton() bool - // ForwardButton represents the right button of the center cluster. + // ForwardButton returns the right button of the center cluster. ForwardButton() bool - // BackButton represents the left button of the center cluster. + // BackButton returns the left button of the center cluster. BackButton() bool // Pulse causes the Gamepad controller to vibrate with the specified From 1fdaf04edb240a47e4a69b1a6ac0b9478eb60668 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 21:51:54 +0300 Subject: [PATCH 29/59] app: document key codes --- app/keyboard.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/app/keyboard.go b/app/keyboard.go index a5806bb4..8675ba5b 100644 --- a/app/keyboard.go +++ b/app/keyboard.go @@ -64,88 +64,171 @@ func (a KeyboardAction) String() string { } const ( + // KeyCodeEscape indicates the Escape key. KeyCodeEscape KeyCode = 1 + iota + // KeyCodeEnter indicates the Enter key. KeyCodeEnter + // KeyCodeSpace indicates the Space key. KeyCodeSpace + // KeyCodeTab indicates the Tab key. KeyCodeTab + // KeyCodeCaps indicates the Caps Lock key. KeyCodeCaps + // KeyCodeLeftShift indicates the left Shift key. KeyCodeLeftShift + // KeyCodeRightShift indicates the right Shift key. KeyCodeRightShift + // KeyCodeLeftControl indicates the left Control key. KeyCodeLeftControl + // KeyCodeRightControl indicates the right Control key. KeyCodeRightControl + // KeyCodeLeftAlt indicates the left Alt key. KeyCodeLeftAlt + // KeyCodeRightAlt indicates the right Alt key. KeyCodeRightAlt + // KeyCodeLeftSuper indicates the left Super (Win/Cmd) key. KeyCodeLeftSuper + // KeyCodeRightSuper indicates the right Super (Win/Cmd) key. KeyCodeRightSuper + // KeyCodeBackspace indicates the Backspace key. KeyCodeBackspace + // KeyCodeInsert indicates the Insert key. KeyCodeInsert + // KeyCodeDelete indicates the Delete key. KeyCodeDelete + // KeyCodeHome indicates the Home key. KeyCodeHome + // KeyCodeEnd indicates the End key. KeyCodeEnd + // KeyCodePageUp indicates the Page Up key. KeyCodePageUp + // KeyCodePageDown indicates the Page Down key. KeyCodePageDown + // KeyCodeArrowLeft indicates the left arrow key. KeyCodeArrowLeft + // KeyCodeArrowRight indicates the right arrow key. KeyCodeArrowRight + // KeyCodeArrowUp indicates the up arrow key. KeyCodeArrowUp + // KeyCodeArrowDown indicates the down arrow key. KeyCodeArrowDown + // KeyCodeMinus indicates the minus/hyphen key. KeyCodeMinus + // KeyCodeEqual indicates the equal sign key. KeyCodeEqual + // KeyCodeLeftBracket indicates the left square bracket key. KeyCodeLeftBracket + // KeyCodeRightBracket indicates the right square bracket key. KeyCodeRightBracket + // KeyCodeSemicolon indicates the semicolon key. KeyCodeSemicolon + // KeyCodeComma indicates the comma key. KeyCodeComma + // KeyCodePeriod indicates the period/full stop key. KeyCodePeriod + // KeyCodeSlash indicates the forward slash key. KeyCodeSlash + // KeyCodeBackslash indicates the backslash key. KeyCodeBackslash + // KeyCodeApostrophe indicates the apostrophe/single-quote key. KeyCodeApostrophe + // KeyCodeGraveAccent indicates the grave accent/backtick key. KeyCodeGraveAccent + // KeyCodeA indicates the A key. KeyCodeA + // KeyCodeB indicates the B key. KeyCodeB + // KeyCodeC indicates the C key. KeyCodeC + // KeyCodeD indicates the D key. KeyCodeD + // KeyCodeE indicates the E key. KeyCodeE + // KeyCodeF indicates the F key. KeyCodeF + // KeyCodeG indicates the G key. KeyCodeG + // KeyCodeH indicates the H key. KeyCodeH + // KeyCodeI indicates the I key. KeyCodeI + // KeyCodeJ indicates the J key. KeyCodeJ + // KeyCodeK indicates the K key. KeyCodeK + // KeyCodeL indicates the L key. KeyCodeL + // KeyCodeM indicates the M key. KeyCodeM + // KeyCodeN indicates the N key. KeyCodeN + // KeyCodeO indicates the O key. KeyCodeO + // KeyCodeP indicates the P key. KeyCodeP + // KeyCodeQ indicates the Q key. KeyCodeQ + // KeyCodeR indicates the R key. KeyCodeR + // KeyCodeS indicates the S key. KeyCodeS + // KeyCodeT indicates the T key. KeyCodeT + // KeyCodeU indicates the U key. KeyCodeU + // KeyCodeV indicates the V key. KeyCodeV + // KeyCodeW indicates the W key. KeyCodeW + // KeyCodeX indicates the X key. KeyCodeX + // KeyCodeY indicates the Y key. KeyCodeY + // KeyCodeZ indicates the Z key. KeyCodeZ + // KeyCode0 indicates the 0 key. KeyCode0 + // KeyCode1 indicates the 1 key. KeyCode1 + // KeyCode2 indicates the 2 key. KeyCode2 + // KeyCode3 indicates the 3 key. KeyCode3 + // KeyCode4 indicates the 4 key. KeyCode4 + // KeyCode5 indicates the 5 key. KeyCode5 + // KeyCode6 indicates the 6 key. KeyCode6 + // KeyCode7 indicates the 7 key. KeyCode7 + // KeyCode8 indicates the 8 key. KeyCode8 + // KeyCode9 indicates the 9 key. KeyCode9 + // KeyCodeF1 indicates the F1 function key. KeyCodeF1 + // KeyCodeF2 indicates the F2 function key. KeyCodeF2 + // KeyCodeF3 indicates the F3 function key. KeyCodeF3 + // KeyCodeF4 indicates the F4 function key. KeyCodeF4 + // KeyCodeF5 indicates the F5 function key. KeyCodeF5 + // KeyCodeF6 indicates the F6 function key. KeyCodeF6 + // KeyCodeF7 indicates the F7 function key. KeyCodeF7 + // KeyCodeF8 indicates the F8 function key. KeyCodeF8 + // KeyCodeF9 indicates the F9 function key. KeyCodeF9 + // KeyCodeF10 indicates the F10 function key. KeyCodeF10 + // KeyCodeF11 indicates the F11 function key. KeyCodeF11 + // KeyCodeF12 indicates the F12 function key. KeyCodeF12 ) From d46b3c30d1dec5b5a3ee02f6e4da3d69264261af Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:01:25 +0300 Subject: [PATCH 30/59] app: more godoc improvements --- app/controller.go | 7 +++++++ app/gamepad.go | 11 ++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/controller.go b/app/controller.go index c0d12a89..63392651 100644 --- a/app/controller.go +++ b/app/controller.go @@ -104,6 +104,13 @@ var _ (Controller) = (*LayeredController)(nil) // LayeredController is an implementation of Controller that invokes // the specified controller layers in an order emulating multiple overlays // of a window. +// +// Lifecycle methods (OnCreate, OnResize, OnFramebufferResize, OnRender) are +// called in forward order (first layer first). OnDestroy is called in reverse +// order so that layers are torn down in the opposite order to their creation. +// Event methods (OnKeyboardEvent, OnMouseEvent, OnGamepadEvent, +// OnClipboardEvent, OnCloseRequested) are called in reverse order so that the +// top-most layer gets first opportunity to consume the event. type LayeredController struct { layers []Controller } diff --git a/app/gamepad.go b/app/gamepad.go index 5a95b9e3..65ee9158 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -266,12 +266,12 @@ const ( // GamepadStickRight indicates the right stick. GamepadStickRight - // GamepadStickLeftTrigger indicates the left trigger. Only the Y value - // is applicable. + // GamepadStickLeftTrigger indicates the left trigger. + // Only the Y stick value is applicable and is within the range [0.0, 1.0]. GamepadStickLeftTrigger - // GamepadStickRightTrigger indicates the right trigger. Only the Y value - // is applicable. + // GamepadStickRightTrigger indicates the right trigger. + // Only the Y stick value is applicable and is within the range [0.0, 1.0]. GamepadStickRightTrigger // GamepadStickCount is the total number of gamepad stick enums. @@ -393,7 +393,8 @@ type Gamepad interface { BackButton() bool // Pulse causes the Gamepad controller to vibrate with the specified - // intensity (0.0 to 1.0) for the specified duration. + // intensity for the specified duration. The intensity should be within the + // range [0.0, 1.0] - a value outside that range will be clamped. // // If the device does not have haptic feedback or if this API implementation // does not support it then this method does nothing. From 695211955c26e8e342bd128a5ca71fff46b94a45 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:05:22 +0300 Subject: [PATCH 31/59] app: relocate enum type declarations --- app/gamepad.go | 18 +++++++++--------- app/keyboard.go | 12 ++++++------ app/mouse.go | 12 ++++++------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/app/gamepad.go b/app/gamepad.go index 65ee9158..3822cc61 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -64,6 +64,9 @@ func (s GamepadEvent) String() string { ) } +// GamepadAction is used to specify the type of gamepad action that occurred. +type GamepadAction int + const ( // GamepadActionNone indicates that no action occurred. This is an unlikely // value but allows for more custom event situations. @@ -89,9 +92,6 @@ const ( GamepadActionStickMove ) -// GamepadAction is used to specify the type of gamepad action that occurred. -type GamepadAction int - // String returns a string representation of this event type. func (s GamepadAction) String() string { switch s { @@ -114,6 +114,9 @@ func (s GamepadAction) String() string { } } +// GamepadButton represents the gamepad button. +type GamepadButton int + const ( // GamepadButtonNone indicates that no button is associated with the event. GamepadButtonNone GamepadButton = iota @@ -196,9 +199,6 @@ const ( GamepadButtonCount ) -// GamepadButton represents the gamepad button. -type GamepadButton int - func (b GamepadButton) String() string { switch b { case GamepadButtonNone: @@ -256,6 +256,9 @@ func (b GamepadButton) String() string { } } +// GamepadStick is used to specify a particular stick on the gamepad. +type GamepadStick int + const ( // GamepadStickNone indicates that no axis is associated with the event. GamepadStickNone GamepadStick = iota @@ -278,9 +281,6 @@ const ( GamepadStickCount ) -// GamepadStick is used to specify a particular stick on the gamepad. -type GamepadStick int - // String returns a string representation of this stick. func (s GamepadStick) String() string { switch s { diff --git a/app/keyboard.go b/app/keyboard.go index 8675ba5b..1268e370 100644 --- a/app/keyboard.go +++ b/app/keyboard.go @@ -25,6 +25,9 @@ func (e KeyboardEvent) String() string { ) } +// KeyboardAction is used to specify the type of keyboard action that occurred. +type KeyboardAction int + const ( // KeyboardActionDown indicates that a keyboard key was pressed. KeyboardActionDown KeyboardAction = 1 + iota @@ -44,9 +47,6 @@ const ( KeyboardActionType ) -// KeyboardAction is used to specify the type of keyboard action that occurred. -type KeyboardAction int - // String returns a string representation of this event type. func (a KeyboardAction) String() string { switch a { @@ -63,6 +63,9 @@ func (a KeyboardAction) String() string { } } +// KeyCode represents a keyboard key. +type KeyCode int + const ( // KeyCodeEscape indicates the Escape key. KeyCodeEscape KeyCode = 1 + iota @@ -232,9 +235,6 @@ const ( KeyCodeF12 ) -// KeyCode represents a keyboard key. -type KeyCode int - // String returns a string representation of this key code. func (c KeyCode) String() string { switch c { diff --git a/app/mouse.go b/app/mouse.go index 891d3b88..79e0d00d 100644 --- a/app/mouse.go +++ b/app/mouse.go @@ -48,6 +48,9 @@ func (e MouseEvent) String() string { ) } +// MouseAction represents the type of action performed with the mouse. +type MouseAction int + const ( // MouseActionDown indicates that a mouse button was pressed down. MouseActionDown MouseAction = 1 + iota @@ -77,9 +80,6 @@ const ( MouseActionScroll ) -// MouseAction represents the type of action performed with the mouse. -type MouseAction int - // String returns a string representation of this event type. func (a MouseAction) String() string { switch a { @@ -102,6 +102,9 @@ func (a MouseAction) String() string { } } +// MouseButton represents the mouse button. +type MouseButton int + const ( // MouseButtonLeft specifies the left mouse button. MouseButtonLeft MouseButton = 1 + iota @@ -113,9 +116,6 @@ const ( MouseButtonRight ) -// MouseButton represents the mouse button. -type MouseButton int - // String returns a string representation of this button. func (b MouseButton) String() string { switch b { From 8ef1518a54a23fce5f9070852540068d4cff977b Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:22:07 +0300 Subject: [PATCH 32/59] app: adjustments to enums --- app/gamepad.go | 102 ++++++++++++++++++++++++++---------------------- app/keyboard.go | 41 +++++++++++++------ app/mouse.go | 46 +++++++++++++++------- 3 files changed, 117 insertions(+), 72 deletions(-) diff --git a/app/gamepad.go b/app/gamepad.go index 3822cc61..97110d0d 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -90,27 +90,31 @@ const ( // GamepadActionStickMove indicates that a gamepad stick or trigger was moved. GamepadActionStickMove + + // GamepadActionCount is a sentinel value representing the total number of + // gamepad action enums. + GamepadActionCount ) // String returns a string representation of this event type. func (s GamepadAction) String() string { switch s { case GamepadActionNone: - return "None" + return "NONE" case GamepadActionConnected: - return "Connected" + return "CONNECTED" case GamepadActionDisconnected: - return "Disconnected" + return "DISCONNECTED" case GamepadActionButtonDown: - return "ButtonDown" + return "BUTTON_DOWN" case GamepadActionButtonUp: - return "ButtonUp" + return "BUTTON_UP" case GamepadActionButtonRepeat: - return "ButtonRepeat" + return "BUTTON_REPEAT" case GamepadActionStickMove: - return "StickMove" + return "STICK_MOVE" default: - return "Unknown" + return "UNKNOWN" } } @@ -180,79 +184,84 @@ const ( // GamepadButtonLeftStickLeft indicates the left direction on the left stick. GamepadButtonLeftStickLeft - // GamepadButtonLeftStickRight indicates the right direction on the left stick. + // GamepadButtonLeftStickRight indicates the right direction on the left + // stick. GamepadButtonLeftStickRight // GamepadButtonRightStickUp indicates the up direction on the right stick. GamepadButtonRightStickUp - // GamepadButtonRightStickDown indicates the down direction on the right stick. + // GamepadButtonRightStickDown indicates the down direction on the right + // stick. GamepadButtonRightStickDown - // GamepadButtonRightStickLeft indicates the left direction on the right stick. + // GamepadButtonRightStickLeft indicates the left direction on the right + // stick. GamepadButtonRightStickLeft - // GamepadButtonRightStickRight indicates the right direction on the right stick. + // GamepadButtonRightStickRight indicates the right direction on the right + // stick. GamepadButtonRightStickRight - // GamepadButtonCount is the total number of gamepad buttons enums. + // GamepadButtonCount is a sentinel value representing the total number of + // gamepad button enums. GamepadButtonCount ) func (b GamepadButton) String() string { switch b { case GamepadButtonNone: - return "None" + return "NONE" case GamepadButtonLeftStick: - return "LeftStick" + return "LEFT_STICK" case GamepadButtonRightStick: - return "RightStick" + return "RIGHT_STICK" case GamepadButtonLeftTrigger: - return "LeftTrigger" + return "LEFT_TRIGGER" case GamepadButtonRightTrigger: - return "RightTrigger" + return "RIGHT_TRIGGER" case GamepadButtonLeftBumper: - return "LeftBumper" + return "LEFT_BUMPER" case GamepadButtonRightBumper: - return "RightBumper" + return "RIGHT_BUMPER" case GamepadButtonDpadUp: - return "DpadUp" + return "DPAD_UP" case GamepadButtonDpadDown: - return "DpadDown" + return "DPAD_DOWN" case GamepadButtonDpadLeft: - return "DpadLeft" + return "DPAD_LEFT" case GamepadButtonDpadRight: - return "DpadRight" + return "DPAD_RIGHT" case GamepadButtonActionUp: - return "ActionUp" + return "ACTION_UP" case GamepadButtonActionDown: - return "ActionDown" + return "ACTION_DOWN" case GamepadButtonActionLeft: - return "ActionLeft" + return "ACTION_LEFT" case GamepadButtonActionRight: - return "ActionRight" + return "ACTION_RIGHT" case GamepadButtonForward: - return "Forward" + return "FORWARD" case GamepadButtonBack: - return "Back" + return "BACK" case GamepadButtonLeftStickUp: - return "LeftStickUp" + return "LEFT_STICK_UP" case GamepadButtonLeftStickDown: - return "LeftStickDown" + return "LEFT_STICK_DOWN" case GamepadButtonLeftStickLeft: - return "LeftStickLeft" + return "LEFT_STICK_LEFT" case GamepadButtonLeftStickRight: - return "LeftStickRight" + return "LEFT_STICK_RIGHT" case GamepadButtonRightStickUp: - return "RightStickUp" + return "RIGHT_STICK_UP" case GamepadButtonRightStickDown: - return "RightStickDown" + return "RIGHT_STICK_DOWN" case GamepadButtonRightStickLeft: - return "RightStickLeft" + return "RIGHT_STICK_LEFT" case GamepadButtonRightStickRight: - return "RightStickRight" + return "RIGHT_STICK_RIGHT" default: - return "Unknown" + return "UNKNOWN" } } @@ -277,7 +286,8 @@ const ( // Only the Y stick value is applicable and is within the range [0.0, 1.0]. GamepadStickRightTrigger - // GamepadStickCount is the total number of gamepad stick enums. + // GamepadStickCount is a sentinel value representing the total number of + // gamepad stick enums. GamepadStickCount ) @@ -285,17 +295,17 @@ const ( func (s GamepadStick) String() string { switch s { case GamepadStickNone: - return "None" + return "NONE" case GamepadStickLeft: - return "Left" + return "LEFT" case GamepadStickRight: - return "Right" + return "RIGHT" case GamepadStickLeftTrigger: - return "LeftTrigger" + return "LEFT_TRIGGER" case GamepadStickRightTrigger: - return "RightTrigger" + return "RIGHT_TRIGGER" default: - return "Unknown" + return "UNKNOWN" } } diff --git a/app/keyboard.go b/app/keyboard.go index 1268e370..ff6f1395 100644 --- a/app/keyboard.go +++ b/app/keyboard.go @@ -29,8 +29,11 @@ func (e KeyboardEvent) String() string { type KeyboardAction int const ( + // KeyboardActionNone indicates that no keyboard action is taking place. + KeyboardActionNone KeyboardAction = iota + // KeyboardActionDown indicates that a keyboard key was pressed. - KeyboardActionDown KeyboardAction = 1 + iota + KeyboardActionDown // KeyboardActionUp indicates that a keyboard key was released. KeyboardActionUp @@ -45,11 +48,17 @@ const ( // might be the result of modifiers or special keys that would be hard // to reconstruct from just the key code. KeyboardActionType + + // KeyboardActionCount is a sentinel value representing the total number of + // keyboard action enums. + KeyboardActionCount ) // String returns a string representation of this event type. func (a KeyboardAction) String() string { switch a { + case KeyboardActionNone: + return "NONE" case KeyboardActionDown: return "DOWN" case KeyboardActionUp: @@ -67,8 +76,10 @@ func (a KeyboardAction) String() string { type KeyCode int const ( + // KeyCodeNone indicates that no key is associated with the event. + KeyCodeNone KeyCode = iota // KeyCodeEscape indicates the Escape key. - KeyCodeEscape KeyCode = 1 + iota + KeyCodeEscape // KeyCodeEnter indicates the Enter key. KeyCodeEnter // KeyCodeSpace indicates the Space key. @@ -233,11 +244,17 @@ const ( KeyCodeF11 // KeyCodeF12 indicates the F12 function key. KeyCodeF12 + + // KeyCodeCount is a sentinel value representing the total number of + // key code enums. + KeyCodeCount ) // String returns a string representation of this key code. func (c KeyCode) String() string { switch c { + case KeyCodeNone: + return "NONE" case KeyCodeEscape: return "ESCAPE" case KeyCodeEnter: @@ -249,21 +266,21 @@ func (c KeyCode) String() string { case KeyCodeCaps: return "CAPS" case KeyCodeLeftShift: - return "LSHIFT" + return "LEFT_SHIFT" case KeyCodeRightShift: - return "RSHIFT" + return "RIGHT_SHIFT" case KeyCodeLeftControl: - return "LCTRL" + return "LEFT_CTRL" case KeyCodeRightControl: - return "RCTRL" + return "RIGHT_CTRL" case KeyCodeLeftAlt: - return "LALT" + return "LEFT_ALT" case KeyCodeRightAlt: - return "RALT" + return "RIGHT_ALT" case KeyCodeLeftSuper: - return "LSUPER" + return "LEFT_SUPER" case KeyCodeRightSuper: - return "RSUPER" + return "RIGHT_SUPER" case KeyCodeBackspace: return "BACKSPACE" case KeyCodeInsert: @@ -275,9 +292,9 @@ func (c KeyCode) String() string { case KeyCodeEnd: return "END" case KeyCodePageUp: - return "PGUP" + return "PAGE_UP" case KeyCodePageDown: - return "PGDOWN" + return "PAGE_DOWN" case KeyCodeArrowLeft: return "LEFT" case KeyCodeArrowRight: diff --git a/app/mouse.go b/app/mouse.go index 79e0d00d..3580843d 100644 --- a/app/mouse.go +++ b/app/mouse.go @@ -52,8 +52,11 @@ func (e MouseEvent) String() string { type MouseAction int const ( + // MouseActionNone indicates that no mouse action occurred. + MouseActionNone MouseAction = iota + // MouseActionDown indicates that a mouse button was pressed down. - MouseActionDown MouseAction = 1 + iota + MouseActionDown // MouseActionUp indicates that a mouse button was released. MouseActionUp @@ -78,27 +81,33 @@ const ( // The ScrollX and ScrollY values of the event indicate the offset in // abstract units (comparable to pixels in magnitude). MouseActionScroll + + // MouseActionCount is a sentinel value representing the total number of + // mouse action enums. + MouseActionCount ) // String returns a string representation of this event type. func (a MouseAction) String() string { switch a { + case MouseActionNone: + return "NONE" case MouseActionDown: - return "Down" + return "DOWN" case MouseActionUp: - return "Up" + return "UP" case MouseActionMove: - return "Move" + return "MOVE" case MouseActionEnter: - return "Enter" + return "ENTER" case MouseActionLeave: - return "Leave" + return "LEAVE" case MouseActionDrop: - return "Drop" + return "DROP" case MouseActionScroll: - return "Scroll" + return "SCROLL" default: - return "Unknown" + return "UNKNOWN" } } @@ -106,27 +115,36 @@ func (a MouseAction) String() string { type MouseButton int const ( + // MouseButtonNone indicates that no mouse button is involved. + MouseButtonNone MouseButton = iota + // MouseButtonLeft specifies the left mouse button. - MouseButtonLeft MouseButton = 1 + iota + MouseButtonLeft // MouseButtonMiddle specifies the middle mouse button. MouseButtonMiddle // MouseButtonRight specifies the right mouse button. MouseButtonRight + + // MouseButtonCount is a sentinel value representing the total number of + // mouse button enums. + MouseButtonCount ) // String returns a string representation of this button. func (b MouseButton) String() string { switch b { + case MouseButtonNone: + return "NONE" case MouseButtonLeft: - return "Left" + return "LEFT" case MouseButtonMiddle: - return "Middle" + return "MIDDLE" case MouseButtonRight: - return "Right" + return "RIGHT" default: - return "Unknown" + return "UNKNOWN" } } From ee64dbcd31d6ee91045d37802906db0b45a82979 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:24:52 +0300 Subject: [PATCH 33/59] app: minor godoc change --- app/gamepad.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/gamepad.go b/app/gamepad.go index 97110d0d..4370d7a2 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -39,10 +39,12 @@ type GamepadEvent struct { // Action indicates the action performed with the gamepad. Action GamepadAction - // Button specifies the button for which the event is applicable. + // Button specifies the button for which an event occurred. This is only + // applicable for button events. Button GamepadButton - // Stick specifies the stick for which the event is applicable. + // Stick specifies the stick for which an event occurred. This is only + // applicable for stick move events. Stick GamepadStick // X specifies the horizontal position of the stick. From 0f22c441ff74e629f8d28ac289746d9f1745f8bc Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:31:51 +0300 Subject: [PATCH 34/59] app: minor godoc adjustments --- app/controller.go | 3 +++ app/gamepad.go | 1 + 2 files changed, 4 insertions(+) diff --git a/app/controller.go b/app/controller.go index 63392651..6e1f8cdd 100644 --- a/app/controller.go +++ b/app/controller.go @@ -45,6 +45,9 @@ type Controller interface { // OnClipboardEvent is called whenever the clipboard content has been // requested and the underlying window has managed to retrieve it. + // + // Return true to indicate that the event has been consumed and should + // not be propagated to other potential receivers, otherwise return false. OnClipboardEvent(window Window, event ClipboardEvent) bool // OnRender is called whenever the window would like to be redrawn. diff --git a/app/gamepad.go b/app/gamepad.go index 4370d7a2..1cb6f35d 100644 --- a/app/gamepad.go +++ b/app/gamepad.go @@ -210,6 +210,7 @@ const ( GamepadButtonCount ) +// String returns a string representation of this button. func (b GamepadButton) String() string { switch b { case GamepadButtonNone: From cd03a20cb1939bbf6851c6f988baf42d3e5212a8 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 10 May 2026 22:34:47 +0300 Subject: [PATCH 35/59] project: bump dependencies --- go.mod | 31 +++++++++++++++--------------- go.sum | 60 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index d154565c..9fc7d994 100644 --- a/go.mod +++ b/go.mod @@ -5,37 +5,36 @@ go 1.26 require ( github.com/google/uuid v1.6.0 github.com/mdouchement/hdr v0.2.4 - github.com/mokiat/gblob v0.5.0 + github.com/mokiat/gblob v0.6.0 github.com/mokiat/goexr v0.1.0 github.com/mokiat/gog v0.22.0 github.com/mokiat/gomath v0.16.0 - github.com/onsi/ginkgo/v2 v2.28.1 - github.com/onsi/gomega v1.39.1 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.0 github.com/qmuntal/gltf v0.28.0 github.com/x448/float16 v0.8.4 - golang.org/x/image v0.33.0 + golang.org/x/image v0.40.0 golang.org/x/sync v0.20.0 ) require ( - github.com/BurntSushi/toml v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect - golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - honnef.co/go/tools v0.6.1 // indirect + honnef.co/go/tools v0.7.0 // indirect ) tool ( diff --git a/go.sum b/go.sum index 71f51649..4724d510 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -20,8 +20,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -33,26 +33,26 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mdouchement/hdr v0.2.4 h1:k0ojx7smWvWw8En2BjUnb144j48gAExu5mv+ogNrkTc= github.com/mdouchement/hdr v0.2.4/go.mod h1:uezK2oUhYtoRLkTD0J4ryiOsu/oWLjRXx0I/92mIRmQ= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/mokiat/gblob v0.5.0 h1:CF4/aWavIvR2sjioEQSMktvmivu8H2OQ1lnbh6poICI= -github.com/mokiat/gblob v0.5.0/go.mod h1:lG1XkaKsZ06gAxuBPChKLrKXKzOA4xnIZtGFg7VoFBk= +github.com/mokiat/gblob v0.6.0 h1:upRc5LfkGnobahzeGy1q5W3CtkdgBcCi7M5Kb/UlTWs= +github.com/mokiat/gblob v0.6.0/go.mod h1:Yt8LHkkTIZvRD+AslmG6fBsEOozr5OJY4YZVKDYizeg= github.com/mokiat/goexr v0.1.0 h1:zoDvzvIjs/GpkxJDVcCP6GafLp1nOuNDef9JL8KSd2A= github.com/mokiat/goexr v0.1.0/go.mod h1:KhERYaXCcY2ZEaTg1/LyzJ7pxdj/q3V1TxgViG86ck4= github.com/mokiat/gog v0.22.0 h1:LUfqgvJHpUlre5JVx10fsipHnqo5fmCEiZ2RWBlNgG4= github.com/mokiat/gog v0.22.0/go.mod h1:0tl6srnQjC9ZYKAQkvLrrXMblFMmqqjoOWLm+LkRAEo= github.com/mokiat/gomath v0.16.0 h1:RST7h5F7+UGLs5EeOoeNVX5d2FQB50qmUYpJRmMIyi0= github.com/mokiat/gomath v0.16.0/go.mod h1:ixaVMF1VIeH0C3oyIWEwUAxB3ggzzKgcoWZWOKe2DdU= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= 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/qmuntal/gltf v0.28.0 h1:C4A1temWMPtcI2+qNfpfRq8FEJxoBGUN3ZZM8BCc+xU= @@ -73,22 +73,22 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6 h1:8dPTIY8FDvi6k5oSD/GuDbs0QyC+A53U8psHrD7K3jw= -golang.org/x/exp/typeparams v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= -golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= -golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a h1:H06+n8uULVXJdhbdJ9+40jLzRcAPQP2h1UXcs01jzsk= +golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= @@ -98,5 +98,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= -honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= From e768d6f361e7c2674eec87d3f46e4e898ee625d2 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Thu, 14 May 2026 00:14:33 +0300 Subject: [PATCH 36/59] Make ECS API a bit more user-friendly --- docs/manual/ecs/index.md | 25 +++-- game/ecs/bench_test.go | 22 ++--- game/ecs/component.go | 4 +- game/ecs/internal/command.go | 13 +-- game/ecs/operation.go | 54 +++-------- game/ecs/scene.go | 35 +++---- game/ecs/scene_test.go | 174 ++++++++++++++++++++--------------- 7 files changed, 152 insertions(+), 175 deletions(-) diff --git a/docs/manual/ecs/index.md b/docs/manual/ecs/index.md index 1b473f05..01e5a5de 100644 --- a/docs/manual/ecs/index.md +++ b/docs/manual/ecs/index.md @@ -66,8 +66,8 @@ Call `scene.Delete()` when the scene is no longer needed to release all resource ```go id := scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, PositionType, Position{X: 1, Y: 0, Z: 0}) - ecs.AddComponent(op, VelocityType, Velocity{X: 0, Y: 0, Z: 5}) + ecs.SetComponent(op, PositionType, Position{X: 1, Y: 0, Z: 0}) + ecs.SetComponent(op, VelocityType, Velocity{X: 0, Y: 0, Z: 5}) }) ``` @@ -154,25 +154,24 @@ for id, op := range scene.QueryEntitiesIter(movingEntities) { ### Editing a Single Entity -`EditEntity` calls the provided function with an `EditOperation` for the entity. Three operations are available: +`EditEntity` calls the provided function with an `EditOperation` for the entity. Two operations are available: | Function | Effect | |---|---| -| `AddComponent` | Adds a new component. Panics if the entity already has one of that type. | -| `RemoveComponent` | Removes an existing component. Panics if the entity does not have one. | -| `ReplaceComponent` | Updates the value of an existing component without changing the entity's component set. | +| `SetComponent` | Adds the component if the entity does not yet have one of that type, or replaces its value if it does. | +| `UnsetComponent` | Removes the component. No-op if the entity does not have one of that type. | ```go scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, HealthType, Health{Current: 100, Max: 100}) + ecs.SetComponent(op, HealthType, Health{Current: 100, Max: 100}) }) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.ReplaceComponent(op, VelocityType, Velocity{X: 0, Y: 10, Z: 0}) + ecs.SetComponent(op, VelocityType, Velocity{X: 0, Y: 10, Z: 0}) }) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, VelocityType) + ecs.UnsetComponent(op, VelocityType) }) ``` @@ -180,12 +179,12 @@ Multiple operations can be staged in a single `EditEntity` call: ```go scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, VelocityType) - ecs.AddComponent(op, HealthType, Health{Current: 50, Max: 100}) + ecs.UnsetComponent(op, VelocityType) + ecs.SetComponent(op, HealthType, Health{Current: 50, Max: 100}) }) ``` -> `ReplaceComponent` is more efficient than a remove-then-add sequence when only the value needs to change, because it does not move the entity to a different archetype. +> When multiple `SetComponent` or `UnsetComponent` calls target the same component type within one `EditEntity`, only the last one takes effect. Calling `SetComponent` on a component the entity already has is an in-place value update that does not move the entity to a different archetype. ## Conditions @@ -324,7 +323,7 @@ scene.Unfreeze() // depth → 0, mutations committed ```go scene.Freeze() id := scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) scene.HasEntity(id) // true diff --git a/game/ecs/bench_test.go b/game/ecs/bench_test.go index 90f9e89e..e8f42777 100644 --- a/game/ecs/bench_test.go +++ b/game/ecs/bench_test.go @@ -25,11 +25,11 @@ func BenchmarkCheckEntity(b *testing.B) { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{ + ecs.SetComponent(op, positionType, Position{ X: 1.0, Y: 2.0, }) - ecs.AddComponent(op, nameType, Name{ + ecs.SetComponent(op, nameType, Name{ Value: "Alice", }) }) @@ -65,23 +65,23 @@ func BenchmarkEditEntity(b *testing.B) { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{ + ecs.SetComponent(op, positionType, Position{ X: 1.0, Y: 2.0, }) - ecs.AddComponent(op, ageType, Age{ + ecs.SetComponent(op, ageType, Age{ Value: 30, }) }) for b.Loop() { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, nameType, Name{ + ecs.SetComponent(op, nameType, Name{ Value: "Alice", }) }) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, nameType) + ecs.UnsetComponent(op, nameType) }) } } @@ -104,8 +104,8 @@ func BenchmarkQueryEntities(b *testing.B) { for range entityCount { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1.0, Y: 2.0}) - ecs.AddComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) + ecs.SetComponent(op, positionType, Position{X: 1.0, Y: 2.0}) + ecs.SetComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) }) } @@ -150,10 +150,10 @@ func BenchmarkQueryEntitiesMultiArchetype(b *testing.B) { for i := range entityCount { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1.0, Y: 2.0}) - ecs.AddComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) + ecs.SetComponent(op, positionType, Position{X: 1.0, Y: 2.0}) + ecs.SetComponent(op, velocityType, Velocity{X: 0.5, Y: 0.5}) if i%2 == 0 { - ecs.AddComponent(op, tagType, Tag{}) + ecs.SetComponent(op, tagType, Tag{}) } }) } diff --git a/game/ecs/component.go b/game/ecs/component.go index b369a44a..fb97bf89 100644 --- a/game/ecs/component.go +++ b/game/ecs/component.go @@ -32,7 +32,7 @@ func (s *Scope) markInUse() { // Type registers the Go type T as a component type within scope and // returns a [ComponentType] descriptor. The descriptor is used in API -// calls such as [AddComponent], [RemoveComponent], and [GetComponent]. +// calls such as [SetComponent], [UnsetComponent], and [GetComponent]. // // Call this function once per type, typically from a package-level var // initializer. It is not safe for concurrent use. @@ -66,7 +66,7 @@ func Type[T any](scope *Scope) ComponentType[T] { // ComponentType is the typed descriptor for a component of type T. // Obtain one by calling [Type] and pass it to API functions such as -// [AddComponent], [GetComponent], and condition helpers like +// [SetComponent], [GetComponent], and condition helpers like // [HasComponent]. type ComponentType[T any] struct { id internal.TypeID diff --git a/game/ecs/internal/command.go b/game/ecs/internal/command.go index 51fa3443..0c489d27 100644 --- a/game/ecs/internal/command.go +++ b/game/ecs/internal/command.go @@ -10,9 +10,8 @@ const ( CommandTypeCreateEntity CommandTypeEditEntity CommandTypeDeleteEntity - CommandTypeAddComponent - CommandTypeRemoveComponent - CommandTypeReplaceComponent + CommandTypeSetComponent + CommandTypeUnsetComponent ) type CommandType uint32 @@ -31,14 +30,10 @@ type DeleteEntityCommand struct { EntityID ID } -type AddComponentCommand struct { +type SetComponentCommand struct { TypeID TypeID } -type RemoveComponentCommand struct { - TypeID TypeID -} - -type ReplaceComponentCommand struct { +type UnsetComponentCommand struct { TypeID TypeID } diff --git a/game/ecs/operation.go b/game/ecs/operation.go index 8e7d9be2..b4f31705 100644 --- a/game/ecs/operation.go +++ b/game/ecs/operation.go @@ -3,8 +3,8 @@ package ecs import "github.com/mokiat/lacking/game/ecs/internal" // EditOperation is the write handle passed to [Scene.EditEntity] and -// [Scene.CreateEntity] callbacks. Use [AddComponent], [RemoveComponent], -// and [ReplaceComponent] to stage component changes. +// [Scene.CreateEntity] callbacks. Use [SetComponent] and [UnsetComponent] +// to stage component changes. // // Do not create instances directly or retain the pointer beyond the // callback scope. @@ -18,56 +18,30 @@ type EditOperation struct { // [Scene.EditEntity] and [Scene.CreateEntity]. type EditOperationFunc func(op *EditOperation) -// AddComponent stages the addition of a component of type T with the -// given value to the entity being edited. -// -// Panics at commit time if the entity already has a component of type T -// (as determined by the virtual state after prior operations in the same -// edit). -func AddComponent[T any](op *EditOperation, compType ComponentType[T], value T) { +// SetComponent stages the addition of a component of type T with the +// given value to the entity being edited. If the entity already has a component +// of type T, it is replaced with the new value. +func SetComponent[T any](op *EditOperation, compType ComponentType[T], value T) { columnID := op.stager.ComponentColumnID(compType.id) column := compType.storage.Column(columnID) column.SetValue(op.stageRow, value) internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ - CommandType: internal.CommandTypeAddComponent, + CommandType: internal.CommandTypeSetComponent, }) - internal.WriteToBuffer(op.commandBuffer, internal.AddComponentCommand{ + internal.WriteToBuffer(op.commandBuffer, internal.SetComponentCommand{ TypeID: compType.id, }) } -// RemoveComponent stages the removal of the component of type T from -// the entity being edited. -// -// Panics at commit time if the entity does not have a component of -// type T (as determined by the virtual state after prior operations in -// the same edit). -func RemoveComponent[T any](op *EditOperation, compType ComponentType[T]) { - internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ - CommandType: internal.CommandTypeRemoveComponent, - }) - internal.WriteToBuffer(op.commandBuffer, internal.RemoveComponentCommand{ - TypeID: compType.id, - }) -} - -// ReplaceComponent stages a value update for the component of type T on -// the entity being edited. Unlike [RemoveComponent] followed by -// [AddComponent], this does not change the entity's archetype. -// -// Panics at commit time if the entity does not have a component of -// type T (as determined by the virtual state after prior operations in -// the same edit). -func ReplaceComponent[T any](op *EditOperation, compType ComponentType[T], value T) { - columnID := op.stager.ComponentColumnID(compType.id) - column := compType.storage.Column(columnID) - column.SetValue(op.stageRow, value) - +// UnsetComponent stages the removal of the component of type T from +// the entity being edited. If the entity does not have a component of type T, +// this is a no-op. +func UnsetComponent[T any](op *EditOperation, compType ComponentType[T]) { internal.WriteToBuffer(op.commandBuffer, internal.CommandHeader{ - CommandType: internal.CommandTypeReplaceComponent, + CommandType: internal.CommandTypeUnsetComponent, }) - internal.WriteToBuffer(op.commandBuffer, internal.ReplaceComponentCommand{ + internal.WriteToBuffer(op.commandBuffer, internal.UnsetComponentCommand{ TypeID: compType.id, }) } diff --git a/game/ecs/scene.go b/game/ecs/scene.go index 8e37b83d..43083b32 100644 --- a/game/ecs/scene.go +++ b/game/ecs/scene.go @@ -269,14 +269,14 @@ func (s *Scene) ReadEntity(id ID, fn func(*ReadOperation)) { } // EditEntity calls fn with an [EditOperation] for the entity identified -// by id. Use [AddComponent], [RemoveComponent], and [ReplaceComponent] -// inside fn to stage structural or value changes. +// by id. Use [SetComponent] and [UnsetComponent] inside fn to stage +// structural or value changes. // -// Panics if a component is added that the entity already has, or one is -// removed or replaced that the entity does not have, as determined by -// the virtual component state after each prior operation in the same -// edit. When multiple operations target the same component type, only -// the last one takes effect. +// [SetComponent] adds the component if the entity does not yet have one +// of that type, or replaces its value if it does. [UnsetComponent] +// removes the component, or is a no-op if the entity does not have one. +// When multiple operations target the same component type, only the last +// one takes effect. // // EditEntity may be called during a query; the edit is deferred until // the query completes. @@ -580,29 +580,16 @@ commandLoop: header := internal.ReadFromBuffer[internal.CommandHeader](s.commandBuffer) switch header.CommandType { - case internal.CommandTypeAddComponent: - cmd := internal.ReadFromBuffer[internal.AddComponentCommand](s.commandBuffer) - if mask.HasType(cmd.TypeID) { - panic(fmt.Errorf("cannot add component of type %v that the entity already has", cmd.TypeID)) - } + case internal.CommandTypeSetComponent: + cmd := internal.ReadFromBuffer[internal.SetComponentCommand](s.commandBuffer) mask.AddType(cmd.TypeID) changes.AddType(cmd.TypeID) - case internal.CommandTypeRemoveComponent: - cmd := internal.ReadFromBuffer[internal.RemoveComponentCommand](s.commandBuffer) - if !mask.HasType(cmd.TypeID) { - panic(fmt.Errorf("cannot remove component of type %v that the entity does not have", cmd.TypeID)) - } + case internal.CommandTypeUnsetComponent: + cmd := internal.ReadFromBuffer[internal.UnsetComponentCommand](s.commandBuffer) mask.RemoveType(cmd.TypeID) changes.RemoveType(cmd.TypeID) - case internal.CommandTypeReplaceComponent: - cmd := internal.ReadFromBuffer[internal.ReplaceComponentCommand](s.commandBuffer) - if !mask.HasType(cmd.TypeID) { - panic(fmt.Errorf("cannot replace component of type %v that the entity does not have", cmd.TypeID)) - } - changes.AddType(cmd.TypeID) - case internal.CommandTypeEndOfSequence: break commandLoop diff --git a/game/ecs/scene_test.go b/game/ecs/scene_test.go index 1056259f..e4b03b86 100644 --- a/game/ecs/scene_test.go +++ b/game/ecs/scene_test.go @@ -83,22 +83,22 @@ var _ = Describe("Scene", func() { Expect(scene.HasEntity(id2)).To(BeTrue()) }) - Specify("can add components to entity", func() { + Specify("can set components on entity", func() { id := scene.CreateEntity(nil) pos := Position{X: 1, Y: 2} name := Name{Value: "Alice"} scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, pos) - ecs.AddComponent(op, nameType, name) + ecs.SetComponent(op, positionType, pos) + ecs.SetComponent(op, nameType, name) }) }) Specify("can create entity with initial components", func() { id := scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) - ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, nameType, Name{Value: "Alice"}) }) Expect(scene.CheckEntity(id, ecs.HasComponent(positionType))).To(BeTrue()) @@ -122,7 +122,7 @@ var _ = Describe("Scene", func() { }) id := scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(entered).To(ConsistOf(id)) }) @@ -137,8 +137,8 @@ var _ = Describe("Scene", func() { name := Name{Value: "Alice"} scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, pos) - ecs.AddComponent(op, nameType, name) + ecs.SetComponent(op, positionType, pos) + ecs.SetComponent(op, nameType, name) }) }) @@ -197,7 +197,7 @@ var _ = Describe("Scene", func() { When("a component is removed", func() { BeforeEach(func() { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) + ecs.UnsetComponent(op, positionType) }) }) @@ -251,9 +251,9 @@ var _ = Describe("Scene", func() { Expect(age).To(BeNil()) }) - Specify("can replace a component value", func() { + Specify("SetComponent on an existing component replaces its value", func() { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.ReplaceComponent(op, positionType, Position{X: 10, Y: 20}) + ecs.SetComponent(op, positionType, Position{X: 10, Y: 20}) }) var pos *Position @@ -263,10 +263,32 @@ var _ = Describe("Scene", func() { Expect(*pos).To(Equal(Position{X: 10, Y: 20})) }) - Specify("can remove and re-add a component in the same edit, updating its value", func() { + Specify("SetComponent twice in one edit keeps the last value", func() { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) - ecs.AddComponent(op, positionType, Position{X: 10, Y: 20}) + ecs.SetComponent(op, positionType, Position{X: 10, Y: 20}) + ecs.SetComponent(op, positionType, Position{X: 30, Y: 40}) + }) + + var pos *Position + scene.ReadEntity(id, func(op *ecs.ReadOperation) { + pos = ecs.GetComponent(op, positionType) + }) + Expect(*pos).To(Equal(Position{X: 30, Y: 40})) + }) + + Specify("UnsetComponent on a missing component is a no-op", func() { + Expect(func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, ageType) + }) + }).ToNot(Panic()) + Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) + }) + + Specify("can unset and re-set a component in the same edit, updating its value", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, positionType) + ecs.SetComponent(op, positionType, Position{X: 10, Y: 20}) }) Expect(scene.CheckEntity(id, ecs.HasComponent(positionType))).To(BeTrue()) @@ -278,10 +300,10 @@ var _ = Describe("Scene", func() { Expect(*pos).To(Equal(Position{X: 10, Y: 20})) }) - Specify("adding and removing the same component in the same edit is a no-op", func() { + Specify("setting and unsetting the same component in the same edit is a no-op", func() { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) - ecs.RemoveComponent(op, ageType) + ecs.SetComponent(op, ageType, Age{Value: 42}) + ecs.UnsetComponent(op, ageType) }) Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) @@ -298,19 +320,19 @@ var _ = Describe("Scene", func() { BeforeEach(func() { entityPosName = scene.CreateEntity(nil) scene.EditEntity(entityPosName, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) - ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, nameType, Name{Value: "Alice"}) }) entityPosAge = scene.CreateEntity(nil) scene.EditEntity(entityPosAge, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) - ecs.AddComponent(op, ageType, Age{Value: 30}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, ageType, Age{Value: 30}) }) entityNameOnly = scene.CreateEntity(nil) scene.EditEntity(entityNameOnly, func(op *ecs.EditOperation) { - ecs.AddComponent(op, nameType, Name{Value: "Bob"}) + ecs.SetComponent(op, nameType, Name{Value: "Bob"}) }) }) @@ -402,7 +424,7 @@ var _ = Describe("Scene", func() { scene.QueryEntities(ecs.HasComponent(positionType), func(id ecs.ID, op *ecs.ReadOperation) bool { positionsDuringQuery = append(positionsDuringQuery, *ecs.GetComponent(op, positionType)) scene.EditEntity(id, func(editOp *ecs.EditOperation) { - ecs.ReplaceComponent(editOp, positionType, Position{X: 99, Y: 99}) + ecs.SetComponent(editOp, positionType, Position{X: 99, Y: 99}) }) return true }) @@ -423,7 +445,7 @@ var _ = Describe("Scene", func() { visitCount++ if createdID == ecs.NilID { createdID = scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 99, Y: 99}) + ecs.SetComponent(op, positionType, Position{X: 99, Y: 99}) }) } return true @@ -452,7 +474,7 @@ var _ = Describe("Scene", func() { scene.QueryEntities(ecs.HasComponent(positionType), func(_ ecs.ID, _ *ecs.ReadOperation) bool { scene.QueryEntities(ecs.HasComponent(nameType), func(id ecs.ID, _ *ecs.ReadOperation) bool { scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 99}) + ecs.SetComponent(op, ageType, Age{Value: 99}) }) return true }) @@ -487,7 +509,7 @@ var _ = Describe("Scene", func() { Specify("fires when entity gains the required component", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(entered).To(ConsistOf(id)) }) @@ -495,12 +517,12 @@ var _ = Describe("Scene", func() { Specify("does not fire again when another component is added while condition remains satisfied", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 30}) + ecs.SetComponent(op, ageType, Age{Value: 30}) }) Expect(entered).To(BeEmpty()) }) @@ -508,15 +530,15 @@ var _ = Describe("Scene", func() { Specify("fires again after entity re-gains the component", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) + ecs.UnsetComponent(op, positionType) }) entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) Expect(entered).To(ConsistOf(id)) }) @@ -524,7 +546,7 @@ var _ = Describe("Scene", func() { Specify("does not fire when entity is deleted", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) entered = nil @@ -540,7 +562,7 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(entered).To(ConsistOf(id)) Expect(secondEntered).To(ConsistOf(id)) @@ -554,7 +576,7 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 25}) + ecs.SetComponent(op, ageType, Age{Value: 25}) }) Expect(entered).To(BeEmpty()) }) @@ -570,12 +592,12 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(compositeEntered).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + ecs.SetComponent(op, nameType, Name{Value: "Alice"}) }) Expect(compositeEntered).To(ConsistOf(id)) }) @@ -583,12 +605,12 @@ var _ = Describe("Scene", func() { Specify("does not fire when a component is replaced in place", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.ReplaceComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) Expect(entered).To(BeEmpty()) }) @@ -596,13 +618,13 @@ var _ = Describe("Scene", func() { Specify("does not fire when a component is removed and re-added in the same edit", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.UnsetComponent(op, positionType) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) Expect(entered).To(BeEmpty()) }) @@ -625,7 +647,7 @@ var _ = Describe("Scene", func() { entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 30}) + ecs.SetComponent(op, ageType, Age{Value: 30}) }) Expect(entered).To(BeEmpty()) }) @@ -634,12 +656,12 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) entered = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(entered).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) + ecs.UnsetComponent(op, positionType) }) Expect(entered).To(ConsistOf(id)) }) @@ -668,12 +690,12 @@ var _ = Describe("Scene", func() { Specify("fires when entity loses the required component", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(exited).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) + ecs.UnsetComponent(op, positionType) }) Expect(exited).To(ConsistOf(id)) }) @@ -681,7 +703,7 @@ var _ = Describe("Scene", func() { Specify("fires when entity with the component is deleted", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(exited).To(BeEmpty()) @@ -698,13 +720,13 @@ var _ = Describe("Scene", func() { Specify("does not fire when an unrelated component is removed", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) - ecs.AddComponent(op, ageType, Age{Value: 30}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, ageType, Age{Value: 30}) }) exited = nil scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, ageType) + ecs.UnsetComponent(op, ageType) }) Expect(exited).To(BeEmpty()) }) @@ -717,7 +739,7 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 25}) + ecs.SetComponent(op, ageType, Age{Value: 25}) }) scene.DeleteEntity(id) Expect(exited).To(BeEmpty()) @@ -726,12 +748,12 @@ var _ = Describe("Scene", func() { Specify("does not fire when a component is replaced in place", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(exited).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.ReplaceComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) Expect(exited).To(BeEmpty()) }) @@ -739,13 +761,13 @@ var _ = Describe("Scene", func() { Specify("does not fire when a component is removed and re-added in the same edit", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(exited).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.UnsetComponent(op, positionType) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) Expect(exited).To(BeEmpty()) }) @@ -763,7 +785,7 @@ var _ = Describe("Scene", func() { Expect(exited).To(BeEmpty()) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(exited).To(ConsistOf(id)) }) @@ -785,8 +807,8 @@ var _ = Describe("Scene", func() { BeforeEach(func() { id = scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) - ecs.AddComponent(op, nameType, Name{Value: "Alice"}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, nameType, Name{Value: "Alice"}) }) }) @@ -796,10 +818,10 @@ var _ = Describe("Scene", func() { scene.ReadEntity(id, func(op *ecs.ReadOperation) { pos = ecs.GetComponent(op, positionType) }) - // AddComponent would normally move the entity to a new archetype, + // SetComponent would normally move the entity to a new archetype, // invalidating pos, but Freeze keeps the mutation buffered. scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.SetComponent(op, ageType, Age{Value: 42}) }) Expect(*pos).To(Equal(Position{X: 1, Y: 2})) scene.Unfreeze() @@ -808,7 +830,7 @@ var _ = Describe("Scene", func() { Specify("structural mutations are deferred while frozen and committed on Unfreeze", func() { scene.Freeze() scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.SetComponent(op, ageType, Age{Value: 42}) }) Expect(scene.CheckEntity(id, ecs.HasComponent(ageType))).To(BeFalse()) @@ -833,7 +855,7 @@ var _ = Describe("Scene", func() { scene.Freeze() scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.SetComponent(op, ageType, Age{Value: 42}) }) Expect(entered).To(BeEmpty()) @@ -849,7 +871,7 @@ var _ = Describe("Scene", func() { scene.Freeze() scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.RemoveComponent(op, positionType) + ecs.UnsetComponent(op, positionType) }) Expect(exited).To(BeEmpty()) @@ -861,7 +883,7 @@ var _ = Describe("Scene", func() { scene.Freeze() scene.Freeze() scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.SetComponent(op, ageType, Age{Value: 42}) }) scene.Unfreeze() @@ -881,7 +903,7 @@ var _ = Describe("Scene", func() { BeforeEach(func() { scene.Freeze() frozenID = scene.CreateEntity(func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 7, Y: 8}) + ecs.SetComponent(op, positionType, Position{X: 7, Y: 8}) }) }) @@ -922,7 +944,7 @@ var _ = Describe("Scene", func() { id := scene.CreateEntity(nil) scene.EditEntity(id, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(createdID).ToNot(Equal(ecs.NilID)) @@ -943,7 +965,7 @@ var _ = Describe("Scene", func() { triggerID := scene.CreateEntity(nil) lacksPositionEntered = nil // reset: clear notification from triggerID's own creation scene.EditEntity(triggerID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) // createdID is created (deferred) after HasPosition enter fires; @@ -956,13 +978,13 @@ var _ = Describe("Scene", func() { scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { scene.EditEntity(targetID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 42}) + ecs.SetComponent(op, ageType, Age{Value: 42}) }) }) triggerID := scene.CreateEntity(nil) scene.EditEntity(triggerID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(scene.CheckEntity(targetID, ecs.HasComponent(ageType))).To(BeTrue()) @@ -978,13 +1000,13 @@ var _ = Describe("Scene", func() { scene.SubscribeEnter(ecs.HasComponent(positionType), func(_ ecs.ID) { scene.EditEntity(targetID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 99}) + ecs.SetComponent(op, ageType, Age{Value: 99}) }) }) triggerID := scene.CreateEntity(nil) scene.EditEntity(triggerID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) Expect(ageEntered).To(ConsistOf(targetID)) @@ -993,7 +1015,7 @@ var _ = Describe("Scene", func() { Specify("entity deleted in exit notification is removed after the triggering operation", func() { sideEffectID := scene.CreateEntity(nil) scene.EditEntity(sideEffectID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) scene.SubscribeExit(ecs.HasComponent(positionType), func(id ecs.ID) { @@ -1004,7 +1026,7 @@ var _ = Describe("Scene", func() { triggerID := scene.CreateEntity(nil) scene.EditEntity(triggerID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(op, positionType, Position{X: 1, Y: 2}) }) scene.DeleteEntity(triggerID) @@ -1019,7 +1041,7 @@ var _ = Describe("Scene", func() { targetID := scene.CreateEntity(nil) scene.EditEntity(targetID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, positionType, Position{X: 3, Y: 4}) + ecs.SetComponent(op, positionType, Position{X: 3, Y: 4}) }) posExited = nil // reset @@ -1029,7 +1051,7 @@ var _ = Describe("Scene", func() { triggerID := scene.CreateEntity(nil) scene.EditEntity(triggerID, func(op *ecs.EditOperation) { - ecs.AddComponent(op, ageType, Age{Value: 10}) + ecs.SetComponent(op, ageType, Age{Value: 10}) }) Expect(posExited).To(ConsistOf(targetID)) From 256ed5e8c259966111c3b9a02ad1003262b6117a Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 16 May 2026 15:21:37 +0300 Subject: [PATCH 37/59] audio: make compressor node fully configurable --- audio/node.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ audio/nop.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/audio/node.go b/audio/node.go index d56da427..6b81abf8 100644 --- a/audio/node.go +++ b/audio/node.go @@ -177,10 +177,54 @@ type ReverbNode interface { type CompressorNode interface { UserNode + // Attack returns the attack time in seconds. + // + // Default value is 0.003 seconds. + Attack() float32 + + // SetAttack sets the attack time in seconds. + // + // The value will be clamped to the range [0.0, 1.0]. + SetAttack(attack float32) + + // Release returns the release time in seconds. + // + // Default value is 0.25 seconds. + Release() float32 + + // SetRelease sets the release time in seconds. + // + // The value will be clamped to the range [0.0, 1.0]. + SetRelease(release float32) + + // Ratio returns the compression ratio. + // + // Default value is 12.0. + Ratio() float32 + + // SetRatio sets the compression ratio. + // + // The value will be clamped to the range [1.0, 20.0]. + SetRatio(ratio float32) + + // Knee returns the knee width in decibels. + // + // Default value is 30.0 dB. + Knee() float32 + + // SetKnee sets the knee width in decibels. + // + // The value will be clamped to the range [0.0, 40.0]. + SetKnee(knee float32) + // Threshold returns the threshold level in decibels. + // + // Default value is -24.0 dB. Threshold() float32 // SetThreshold sets the threshold level in decibels. + // + // The value will be clamped to the range [-100.0, 0.0]. SetThreshold(threshold float32) } diff --git a/audio/nop.go b/audio/nop.go index 815f42d3..1613cd60 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -281,9 +281,45 @@ func (n *nopReverbNode) SetRoomSize(roomSize float32) { type nopCompressorNode struct { nopUserNode + attack float32 + release float32 + ratio float32 + knee float32 threshold float32 } +func (n *nopCompressorNode) Attack() float32 { + return n.attack +} + +func (n *nopCompressorNode) SetAttack(attack float32) { + n.attack = attack +} + +func (n *nopCompressorNode) Release() float32 { + return n.release +} + +func (n *nopCompressorNode) SetRelease(release float32) { + n.release = release +} + +func (n *nopCompressorNode) Ratio() float32 { + return n.ratio +} + +func (n *nopCompressorNode) SetRatio(ratio float32) { + n.ratio = ratio +} + +func (n *nopCompressorNode) Knee() float32 { + return n.knee +} + +func (n *nopCompressorNode) SetKnee(knee float32) { + n.knee = knee +} + func (n *nopCompressorNode) Threshold() float32 { return n.threshold } From ea778d9c5a8f77d96f817af9e73f3daf8faa289e Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 16 May 2026 15:21:54 +0300 Subject: [PATCH 38/59] Use float32 in audio API --- audio/playback.go | 4 ++-- ui/sound.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/audio/playback.go b/audio/playback.go index bf4ecba4..5d93689d 100644 --- a/audio/playback.go +++ b/audio/playback.go @@ -11,11 +11,11 @@ type PlayInfo struct { // Gain indicates the amount of volume, where 1.0 is max and 0.0 is min. // // If not specified, the default value is 1.0. - Gain opt.T[float64] + Gain opt.T[float32] // Pan indicates the sound panning, where -1.0 is left, 0.0 is center, and // 1.0 is right. - Pan float64 + Pan float32 } // Playback represents the audio playback of a media file. diff --git a/ui/sound.go b/ui/sound.go index 2dab239a..f1081041 100644 --- a/ui/sound.go +++ b/ui/sound.go @@ -5,13 +5,13 @@ import ( "github.com/mokiat/lacking/audio" ) -var globalAudioGain = 1.0 +var globalAudioGain = float32(1.0) -func GlobalAudioGain() float64 { +func GlobalAudioGain() float32 { return globalAudioGain } -func SetGlobalAudioGain(gain float64) { +func SetGlobalAudioGain(gain float32) { globalAudioGain = gain } @@ -27,7 +27,7 @@ type Sound struct { media audio.Media } -func (s *Sound) Play(gain float64) { +func (s *Sound) Play(gain float32) { if s == nil { return } From 48168eb435dce7bca85fee8583a4b69e5a45b8de Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 16 May 2026 20:01:00 +0300 Subject: [PATCH 39/59] audio: improvements to api and godoc --- audio/api.go | 15 +++--- audio/doc.go | 15 +++++- audio/listener.go | 7 +++ audio/node.go | 117 ++++++++++++++++++++++++++++++++++++++++++++-- audio/nop.go | 74 +++++++++++++++++++++++++---- 5 files changed, 207 insertions(+), 21 deletions(-) diff --git a/audio/api.go b/audio/api.go index 1a7c803f..7ba5a629 100644 --- a/audio/api.go +++ b/audio/api.go @@ -2,7 +2,7 @@ package audio // API provides access to a low-level audio manipulation and playback. // -// All functions in this API need to be called from the main thread. +// All methods must be called from the UI thread. type API interface { // SampleRate returns the audio sample rate used by the API (i.e. how @@ -59,17 +59,20 @@ type API interface { // CreateCompressorNode creates a new compressor node. CreateCompressorNode() CompressorNode - // CreateConnectorNode creates a new connector node. It is a no-op node that - // can be used to connect other nodes together without affecting the audio - // signal. + // CreateConnectorNode creates a new connector node. It is a pass-through + // node that forwards its input signal unchanged, useful as a named + // connection point in a larger node graph. CreateConnectorNode() ConnectorNode // Chain connects the specified nodes in sequence. This is a convenience - // function that uses the Connect method of the API. Beware that it may - // incur allocations due to variadic parameters. + // function that uses [API.Connect]. Beware that it may incur allocations + // due to variadic parameters. Chain(nodes ...Node) // Connect connects the source node to the target node. + // + // The audio signal from the source node will be added to the audio + // signal from any other nodes that are already connected to the target node. Connect(source, target Node) // Disconnect disconnects the source node from the target node. diff --git a/audio/doc.go b/audio/doc.go index 389af1a2..d554ed71 100644 --- a/audio/doc.go +++ b/audio/doc.go @@ -1,2 +1,15 @@ -// Package audio provides audio processing and playback functionality. +// Package audio defines the audio API used by the engine. +// +// The API is built around a node graph model. Audio sources (e.g. +// [PlaybackNode], [OscillatorNode]) produce signals that flow through +// processing nodes (e.g. [GainNode], [ReverbNode]) and ultimately reach the +// output node returned by [API.Output]. Nodes are connected with [API.Connect] +// and disconnected with [API.Disconnect]. When multiple sources are connected +// to the same target their signals are mixed additively. +// +// All created nodes implement [UserNode] and must be explicitly deleted via +// [UserNode.Delete] when no longer needed, otherwise resources will leak. +// +// The nop implementation ([NewNopAPI]) provides a fully functional but silent +// API suitable for headless operation and testing. package audio diff --git a/audio/listener.go b/audio/listener.go index f8087323..0303beda 100644 --- a/audio/listener.go +++ b/audio/listener.go @@ -3,6 +3,13 @@ package audio import "github.com/mokiat/gomath/sprec" // SpatialListener represents a listener in 3D space for spatial audio. +// +// The listener's position and orientation can be used to create spatial audio +// effects, such as panning and distance attenuation, for [SpatialNode] sources. +// +// Distance attenuation is applied to [SpatialNode] sources based on the +// distance between the source and the listener. The attenuation model is +// "inverse", where the gain is calculated as 1.0 / max(1.0, distance). type SpatialListener interface { // Position returns the 3D position of the listener. diff --git a/audio/node.go b/audio/node.go index 6b81abf8..2bfc3f6c 100644 --- a/audio/node.go +++ b/audio/node.go @@ -2,6 +2,58 @@ package audio import "github.com/mokiat/gomath/sprec" +const ( + // DefaultFrequency is the default frequency for OscillatorNode. + DefaultFrequency = 440.0 + + // DefaultGain is the default gain factor for GainNode, representing no + // change to the audio signal. + DefaultGain = 1.0 + + // DefaultPan is the default pan value for PanNode, representing a + // centered audio signal. + DefaultPan = 0.0 + + // DefaultCutoffFrequency is the default cutoff frequency for HighPassNode + // and LowPassNode. + DefaultCutoffFrequency = 350.0 + + // DefaultDelay is the default delay time for DelayNode, representing no + // delay. + DefaultDelay = 0.0 + + // DefaultRoomSize is the default room size for ReverbNode, representing a + // small room. + DefaultRoomSize = 0.3 + + // DefaultDamping is the default damping factor for ReverbNode, representing + // a moderate amount of damping. + DefaultDamping = 0.5 + + // DefaultDry is the default dry level for ReverbNode, representing the + // original signal at full gain. + DefaultDry = 1.0 + + // DefaultWet is the default wet level for ReverbNode, representing the + // reverberated signal at half gain. + DefaultWet = 0.5 + + // DefaultAttack is the default attack time for CompressorNode. + DefaultAttack = 0.003 + + // DefaultRelease is the default release time for CompressorNode. + DefaultRelease = 0.25 + + // DefaultRatio is the default compression ratio for CompressorNode. + DefaultRatio = 12.0 + + // DefaultKnee is the default knee width for CompressorNode. + DefaultKnee = 30.0 + + // DefaultThreshold is the default threshold level for CompressorNode. + DefaultThreshold = -24.0 +) + // Node represents a node in a chain of audio elements. Each node produces // audio data which can be synthesized, processed, or played back. type Node interface { @@ -32,12 +84,11 @@ type PlaybackNode interface { // Stop stops the playback of the audio. Stop() - // Resume resumes the playback of the audio. + // Resume resumes a paused playback from where it was paused. // - // If the playback is already playing, this method has no effect. - // - // If the playback is stopped, this method has the same effect as Start with - // an offset of 0. + // If the playback is already playing, this method has no effect. If the + // playback is stopped rather than paused, this method starts from the + // beginning (equivalent to Start(0)). Resume() // Pause pauses the playback of the audio. @@ -74,6 +125,8 @@ type OscillatorNode interface { UserNode // Frequency returns the frequency of the oscillator in Hertz. + // + // Default value is 440.0 Hz (A4 note). Frequency() float32 // SetFrequency sets the frequency of the oscillator in Hertz. @@ -86,9 +139,16 @@ type GainNode interface { UserNode // Gain returns the gain factor applied to the audio signal. + // + // A value of 1.0 means no change, 0.0 is silence, and values greater than + // 1.0 amplify the signal. + // + // Default value is 1.0. Gain() float32 // SetGain sets the gain factor applied to the audio signal. + // + // The value must be non-negative. SetGain(gain float32) } @@ -99,6 +159,8 @@ type PanNode interface { // Pan returns the pan value, where -1.0 is full left, 0.0 is center, and // 1.0 is full right. + // + // Default value is 0.0 (center). Pan() float32 // SetPan sets the pan value, where -1.0 is full left, 0.0 is center, and @@ -107,6 +169,8 @@ type PanNode interface { } // SpatialNode represents an audio node that provides spatial audio effects. +// Implementations must apply inverse distance attenuation relative to the +// [SpatialListener] returned by [API.SpatialListener]. type SpatialNode interface { UserNode @@ -124,6 +188,8 @@ type HighPassNode interface { // CutoffFrequency returns the cutoff frequency of the high-pass filter in // Hertz. + // + // Default value is 350.0 Hz. CutoffFrequency() float32 // SetCutoffFrequency sets the cutoff frequency of the high-pass filter in @@ -138,6 +204,8 @@ type LowPassNode interface { // CutoffFrequency returns the cutoff frequency of the low-pass filter in // Hertz. + // + // Default value is 350.0 Hz. CutoffFrequency() float32 // SetCutoffFrequency sets the cutoff frequency of the low-pass filter in @@ -151,6 +219,8 @@ type DelayNode interface { UserNode // DelayTime returns the delay time in seconds. + // + // Default value is 0.0 seconds (no delay). DelayTime() float32 // SetDelayTime sets the delay time in seconds. @@ -166,10 +236,47 @@ type ReverbNode interface { UserNode // RoomSize returns the size of the virtual room for the reverb effect. + // + // The value is in the range [0.0, 1.0]. Default value is 0.3. RoomSize() float32 // SetRoomSize sets the size of the virtual room for the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. SetRoomSize(size float32) + + // Damping returns the damping factor of the reverb effect. + // + // Higher values cause high frequencies to decay faster, simulating + // absorptive surfaces. + // + // Default value is 0.5. + Damping() float32 + + // SetDamping sets the damping factor of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetDamping(damping float32) + + // Dry returns the dry level of the reverb effect. + // + // Default value is 1.0. + Dry() float32 + + // SetDry sets the dry level of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetDry(dry float32) + + // Wet returns the wet level of the reverb effect. + // + // Default value is 0.5. + Wet() float32 + + // SetWet sets the wet level of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetWet(wet float32) } // CompressorNode represents an audio node that applies dynamic range diff --git a/audio/nop.go b/audio/nop.go index 1613cd60..a19c1db2 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -12,11 +12,13 @@ func NewNopAPI() API { listener: &nopListener{ rotation: sprec.IdentityQuat(), }, + output: &nopNode{}, } } type nopAPI struct { listener *nopListener + output *nopNode } func (a *nopAPI) SampleRate() int { @@ -32,7 +34,7 @@ func (a *nopAPI) ParseMedia(info MediaInfo) Media { } func (a *nopAPI) Output() Node { - return nil + return a.output } func (a *nopAPI) SpatialListener() SpatialListener { @@ -44,15 +46,21 @@ func (a *nopAPI) CreatePlaybackNode(media Media, loop bool) PlaybackNode { } func (a *nopAPI) CreateOscillatorNode() OscillatorNode { - return &nopOscillatorNode{} + return &nopOscillatorNode{ + frequency: DefaultFrequency, + } } func (a *nopAPI) CreateGainNode() GainNode { - return &nopGainNode{} + return &nopGainNode{ + gain: DefaultGain, + } } func (a *nopAPI) CreatePanNode() PanNode { - return &nopPanNode{} + return &nopPanNode{ + pan: DefaultPan, + } } func (a *nopAPI) CreateSpatialNode() SpatialNode { @@ -60,23 +68,40 @@ func (a *nopAPI) CreateSpatialNode() SpatialNode { } func (a *nopAPI) CreateHighPassNode() HighPassNode { - return &nopHighPassNode{} + return &nopHighPassNode{ + cutoffFrequency: DefaultCutoffFrequency, + } } func (a *nopAPI) CreateLowPassNode() LowPassNode { - return &nopLowPassNode{} + return &nopLowPassNode{ + cutoffFrequency: DefaultCutoffFrequency, + } } func (a *nopAPI) CreateDelayNode() DelayNode { - return &nopDelayNode{} + return &nopDelayNode{ + delayTime: DefaultDelay, + } } func (a *nopAPI) CreateReverbNode() ReverbNode { - return &nopReverbNode{} + return &nopReverbNode{ + roomSize: DefaultRoomSize, + damping: DefaultDamping, + dry: DefaultDry, + wet: DefaultWet, + } } func (a *nopAPI) CreateCompressorNode() CompressorNode { - return &nopCompressorNode{} + return &nopCompressorNode{ + attack: DefaultAttack, + release: DefaultRelease, + ratio: DefaultRatio, + knee: DefaultKnee, + threshold: DefaultThreshold, + } } func (a *nopAPI) CreateConnectorNode() ConnectorNode { @@ -126,6 +151,10 @@ func (l *nopListener) SetRotation(rotation sprec.Quat) { l.rotation = rotation } +type nopNode struct { + Node // marker interface +} + type nopUserNode struct { Node // marker interface } @@ -269,6 +298,9 @@ func (n *nopDelayNode) SetDelayTime(delayTime float32) { type nopReverbNode struct { nopUserNode roomSize float32 + damping float32 + dry float32 + wet float32 } func (n *nopReverbNode) RoomSize() float32 { @@ -279,6 +311,30 @@ func (n *nopReverbNode) SetRoomSize(roomSize float32) { n.roomSize = roomSize } +func (n *nopReverbNode) Damping() float32 { + return n.damping +} + +func (n *nopReverbNode) SetDamping(damping float32) { + n.damping = damping +} + +func (n *nopReverbNode) Dry() float32 { + return n.dry +} + +func (n *nopReverbNode) SetDry(dry float32) { + n.dry = dry +} + +func (n *nopReverbNode) Wet() float32 { + return n.wet +} + +func (n *nopReverbNode) SetWet(wet float32) { + n.wet = wet +} + type nopCompressorNode struct { nopUserNode attack float32 From b622ac7e7495f492aca2fbc69ae62886e6dca9f0 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 16 May 2026 20:37:59 +0300 Subject: [PATCH 40/59] docs: add audio user manual --- docs/manual/audio/index.md | 261 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 262 insertions(+) create mode 100644 docs/manual/audio/index.md diff --git a/docs/manual/audio/index.md b/docs/manual/audio/index.md new file mode 100644 index 00000000..a5c4f099 --- /dev/null +++ b/docs/manual/audio/index.md @@ -0,0 +1,261 @@ +--- +title: Overview +--- + +# Audio + +The `audio` package defines a low-level, node-graph-based audio API. It is the audio equivalent of the `render` package: an abstract interface that is implemented separately for each platform (native desktop, web). Game code is not expected to use this API directly — a higher-level audio API will be provided for that purpose. + +The API is obtained from the application window: + +```go +api := window.AudioAPI() +``` + +## Core Concepts + +| Concept | Description | +|---|---| +| **API** | Entry point. Creates media and nodes, exposes the output node and spatial listener. | +| **Media** | A decoded audio clip loaded into memory. Acts as a data source for `PlaybackNode`. | +| **Sample** | A single stereo audio sample consisting of left and right channel values. | +| **Node** | An element in the audio graph that produces or processes a signal. | +| **UserNode** | A node that owns resources and must be explicitly deleted when no longer needed. | +| **Output** | The terminal sink node of the graph. Signals connected to it are sent to the speakers. | +| **SpatialListener** | Represents the listener's position and orientation in 3D space for spatial audio. | + +## Media + +`Media` represents a decoded audio clip held in memory. It is created from raw PCM samples or from an encoded file (WAV, MP3): + +```go +// From raw PCM samples (must match the API's sample rate). +media := api.CreateMedia(samples) + +// From encoded file bytes. +media := api.ParseMedia(audio.MediaInfo{ + Data: fileBytes, + DataType: audio.MediaDataTypeWAV, // or MediaDataTypeMP3, MediaDataTypeAuto +}) + +// Release when no longer needed. +defer media.Delete() +``` + +It is safe to delete a `Media` after using it to create a `PlaybackNode` — the node retains its own reference to the underlying data. + +### Sample Rate + +The API operates at a fixed sample rate, available via `api.SampleRate()`. Raw samples passed to `CreateMedia` must already be at this rate. The `Resample` utility can be used to convert: + +```go +resampled := audio.Resample(samples, originalRate, api.SampleRate()) +``` + +`SampleCount` calculates how many samples correspond to a given duration: + +```go +count := audio.SampleCount(2.5, api.SampleRate()) // samples for 2.5 seconds +``` + +## Node Graph + +Audio flows through a directed graph of nodes. Sources generate signals, processing nodes transform them, and everything ultimately connects to the output node. When multiple sources are connected to the same target their signals are mixed additively. + +```go +output := api.Output() + +// Connect source directly to output. +api.Connect(source, output) + +// Or use Chain for a linear sequence. +api.Chain(source, gainNode, reverbNode, output) + +// Disconnect when done. +api.Disconnect(source, output) +``` + +All nodes that are no longer needed must be deleted to avoid resource leaks: + +```go +gainNode.Delete() +``` + +## Node Types + +The following node types are available: + +| Node | Factory | Purpose | +|---|---|---| +| `PlaybackNode` | `CreatePlaybackNode` | Plays back a `Media` clip. | +| `OscillatorNode` | `CreateOscillatorNode` | Generates a periodic waveform. | +| `GainNode` | `CreateGainNode` | Scales the signal amplitude. | +| `PanNode` | `CreatePanNode` | Pans the signal between left and right channels. | +| `SpatialNode` | `CreateSpatialNode` | Applies 3D positional audio effects. | +| `HighPassNode` | `CreateHighPassNode` | Removes frequencies below a cutoff. | +| `LowPassNode` | `CreateLowPassNode` | Removes frequencies above a cutoff. | +| `DelayNode` | `CreateDelayNode` | Adds a time delay to the signal. | +| `ReverbNode` | `CreateReverbNode` | Applies a room reverb effect. | +| `CompressorNode` | `CreateCompressorNode` | Applies dynamic range compression. | +| `ConnectorNode` | `CreateConnectorNode` | Pass-through node useful as a named connection point. | + +### PlaybackNode + +Plays back a `Media` clip. Created with an initial loop setting: + +```go +node := api.CreatePlaybackNode(media, false) +defer node.Delete() + +api.Connect(node, api.Output()) + +node.Start(0) // start from the beginning +node.Pause() // pause; resumes from the same position +node.Resume() // resume from where it was paused +node.Stop() // stop and reset position +``` + +Loop playback can be configured after creation, including a sub-range of the clip: + +```go +node.SetLoop(true) +node.SetLoopStart(1.0) // loop from 1.0 s +node.SetLoopEnd(4.5) // to 4.5 s +``` + +### OscillatorNode + +Generates a continuous periodic waveform at a configurable frequency. Default frequency is 440 Hz (A4). + +```go +node := api.CreateOscillatorNode() +defer node.Delete() + +node.SetFrequency(220.0) // A3 +api.Connect(node, api.Output()) +``` + +### GainNode + +Scales the signal amplitude. A gain of `1.0` is unity (no change); `0.0` is silence; values above `1.0` amplify. Default is `1.0`. + +```go +gain := api.CreateGainNode() +defer gain.Delete() + +gain.SetGain(0.5) // half volume +api.Chain(source, gain, api.Output()) +``` + +The `DBToGain` and `GainToDB` utilities convert between decibels and linear gain: + +```go +gain.SetGain(audio.DBToGain(-6.0)) // -6 dB +``` + +### PanNode + +Distributes the signal between left and right channels. The range is `[-1.0, 1.0]` where `-1.0` is full left, `0.0` is center, and `1.0` is full right. Default is `0.0`. + +```go +pan := api.CreatePanNode() +defer pan.Delete() + +pan.SetPan(-0.5) // slightly left +api.Chain(source, pan, api.Output()) +``` + +### Filter Nodes + +`HighPassNode` and `LowPassNode` each expose a single `CutoffFrequency` parameter (in Hz). Default cutoff is 350 Hz for both. + +```go +hp := api.CreateHighPassNode() +defer hp.Delete() +hp.SetCutoffFrequency(80.0) // remove rumble below 80 Hz + +lp := api.CreateLowPassNode() +defer lp.Delete() +lp.SetCutoffFrequency(8000.0) // remove hiss above 8 kHz +``` + +### DelayNode + +Adds a time delay to the signal. Default delay is `0.0` seconds. Implementations must support at least 1 second of delay. + +```go +delay := api.CreateDelayNode() +defer delay.Delete() + +delay.SetDelayTime(0.3) // 300 ms +api.Chain(source, delay, api.Output()) +``` + +### ReverbNode + +Applies a reverb effect with configurable room characteristics and dry/wet mix. + +| Parameter | Default | Range | Description | +|---|---|---|---| +| `RoomSize` | 0.3 | [0.0, 1.0] | Size of the virtual room. | +| `Damping` | 0.5 | [0.0, 1.0] | High-frequency absorption. Higher values simulate softer surfaces. | +| `Dry` | 1.0 | [0.0, 1.0] | Level of the unprocessed signal. | +| `Wet` | 0.5 | [0.0, 1.0] | Level of the reverberated signal. | + +```go +reverb := api.CreateReverbNode() +defer reverb.Delete() + +reverb.SetRoomSize(0.8) +reverb.SetDamping(0.3) +reverb.SetWet(0.4) + +api.Chain(source, reverb, api.Output()) +``` + +### CompressorNode + +Applies dynamic range compression, attenuating signals that exceed a threshold. + +| Parameter | Default | Range | Description | +|---|---|---|---| +| `Threshold` | -24.0 dB | [-100.0, 0.0] | Level above which compression is applied. | +| `Ratio` | 12.0 | [1.0, 20.0] | Compression ratio (input dB : output dB above threshold). | +| `Knee` | 30.0 dB | [0.0, 40.0] | Width of the soft-knee transition around the threshold. | +| `Attack` | 0.003 s | [0.0, 1.0] | Time for compression to engage after the threshold is exceeded. | +| `Release` | 0.25 s | [0.0, 1.0] | Time for compression to disengage after the signal drops below the threshold. | + +```go +comp := api.CreateCompressorNode() +defer comp.Delete() + +comp.SetThreshold(-18.0) +comp.SetRatio(4.0) +api.Chain(source, comp, api.Output()) +``` + +## Spatial Audio + +`SpatialNode` wraps a signal source and applies 3D positional effects relative to the `SpatialListener`. The attenuation model is inverse distance: `gain = 1.0 / max(1.0, distance)`. + +```go +// Configure the listener (typically updated each frame to match the camera). +listener := api.SpatialListener() +listener.SetPosition(cameraPosition) +listener.SetRotation(cameraRotation) + +// Place a sound source in the world. +spatial := api.CreateSpatialNode() +defer spatial.Delete() + +spatial.SetPosition(sprec.Vec3{X: 10, Y: 0, Z: -5}) +api.Chain(playbackNode, spatial, api.Output()) +``` + +## No-op Implementation + +`NewNopAPI` returns a fully functional but silent implementation. All node factories return working objects that store and return their configured values; no audio is produced. This is useful for headless environments and tests: + +```go +api := audio.NewNopAPI() +``` diff --git a/mkdocs.yml b/mkdocs.yml index 14eb6165..d91ecb9e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Getting Started: getting-started.md - User's Guide: - Application: manual/application/index.md + - Audio: manual/audio/index.md - ECS: manual/ecs/index.md - Game: manual/game/index.md - Graphics: From c4c2e92c440e8823dc24ad89bfe6c15fcc32d0e5 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Mon, 25 May 2026 23:12:03 +0300 Subject: [PATCH 41/59] audio: fix resample bug --- audio/sample.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/audio/sample.go b/audio/sample.go index 097e44b6..642c8628 100644 --- a/audio/sample.go +++ b/audio/sample.go @@ -37,6 +37,9 @@ func Resample(samples []Sample, fromRate int, toRate int) []Sample { if newLength <= 0 { return nil } + if newLength == 1 { + return []Sample{samples[0]} + } result := make([]Sample, newLength) step := float64(oldLength-1) / float64(newLength-1) From 478ec6fab75135544d18484d92467064a4ab6978 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Tue, 26 May 2026 23:25:16 +0300 Subject: [PATCH 42/59] audio: add Seconds utility function --- audio/sample.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/audio/sample.go b/audio/sample.go index 642c8628..40f96dcc 100644 --- a/audio/sample.go +++ b/audio/sample.go @@ -22,6 +22,12 @@ func SampleCount(seconds float32, sampleRate int) int { return int(float32(sampleRate) * seconds) } +// Seconds calculates the duration in seconds for a given number of samples +// and sample rate. +func Seconds(sampleCount, sampleRate int) float32 { + return float32(sampleCount) / float32(sampleRate) +} + // Resample resamples the given audio samples from one sample rate to another. func Resample(samples []Sample, fromRate int, toRate int) []Sample { if (fromRate == toRate) || (len(samples) == 0) { From b251858d62c26d982d993d780b1cc0bcf230534a Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 27 May 2026 22:52:28 +0300 Subject: [PATCH 43/59] audio: changes to api design --- audio/api.go | 15 ++++------ audio/decode.go | 47 +++++++++++++++++++++++++++++ audio/format.go | 30 +++++++++++++++++++ audio/frame.go | 56 ++++++++++++++++++++++++++++++++++ audio/media.go | 27 ++++------------- audio/mp3/decoder.go | 61 +++++++++++++++++++++++++++++++++++++ audio/nop.go | 6 +--- audio/sample.go | 68 ------------------------------------------ audio/util.go | 12 ++++++++ audio/wav/decoder.go | 60 +++++++++++++++++++++++++++++++++++++ go.mod | 4 +++ go.sum | 10 +++++++ ui/resource_manager.go | 21 ++++++------- 13 files changed, 304 insertions(+), 113 deletions(-) create mode 100644 audio/decode.go create mode 100644 audio/format.go create mode 100644 audio/frame.go create mode 100644 audio/mp3/decoder.go delete mode 100644 audio/sample.go create mode 100644 audio/wav/decoder.go diff --git a/audio/api.go b/audio/api.go index 7ba5a629..901b7447 100644 --- a/audio/api.go +++ b/audio/api.go @@ -9,17 +9,14 @@ type API interface { // many samples there are in a single second). SampleRate() int - // CreateMedia creates a new Media object from the specified samples. This - // function assumes that the samples match the API's sample rate. + // CreateMedia creates a new Media object from the specified frames. + // + // If the provided frames are not in the sample rate of the API, they will be + // resampled to match the API's sample rate. // // Keep in mind that the implementation may keep a reference to the provided - // samples slice, so it should not be modified after being passed to this - // method. - CreateMedia(samples []Sample) Media - - // ParseMedia creates a new Media object based on the specified raw data info - // by parsing it according to its format. - ParseMedia(info MediaInfo) Media + // data, so it should not be modified after being passed to this method. + CreateMedia(data MediaData) Media // Output returns the output audio node. Output() Node diff --git a/audio/decode.go b/audio/decode.go new file mode 100644 index 00000000..9f83f09d --- /dev/null +++ b/audio/decode.go @@ -0,0 +1,47 @@ +package audio + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" +) + +// DecodeFunc is a function that decodes audio data from an io.Reader and +// returns a slice of audio frames. +type DecodeFunc func(io.Reader) (MediaData, error) + +// Decode decodes an audio that has been encoded in a registered format. +// +// If the format of the audio data cannot be determined, [errors.ErrUnsupported] +// is returned. +func Decode(r io.Reader) (MediaData, string, error) { + in := bufio.NewReader(r) + + decodeFn, name, err := findDecoder(in) + if err != nil { + return MediaData{}, "", err + } + + data, err := decodeFn(in) + return data, name, err +} + +func findDecoder(r *bufio.Reader) (DecodeFunc, string, error) { + registryMu.Lock() + defer registryMu.Unlock() + + for _, f := range registeredFormats { + count := len(f.magic) + actualMagic, err := r.Peek(count) + if err != nil { + return nil, "", fmt.Errorf("error peeking magic prefix: %w", err) + } + if bytes.Equal(f.magic, actualMagic) { + return f.decode, f.name, nil + } + } + + return nil, "", errors.ErrUnsupported +} diff --git a/audio/format.go b/audio/format.go new file mode 100644 index 00000000..b44f3622 --- /dev/null +++ b/audio/format.go @@ -0,0 +1,30 @@ +package audio + +import ( + "sync" +) + +// RegisterFormat registers an audio format for use by [Decode]. +func RegisterFormat(name string, magics []string, decode DecodeFunc) { + registryMu.Lock() + defer registryMu.Unlock() + + for _, magic := range magics { + registeredFormats = append(registeredFormats, formatEntry{ + name: name, + magic: []byte(magic), + decode: decode, + }) + } +} + +var ( + registryMu sync.Mutex + registeredFormats []formatEntry +) + +type formatEntry struct { + name string + magic []byte + decode DecodeFunc +} diff --git a/audio/frame.go b/audio/frame.go new file mode 100644 index 00000000..a4881655 --- /dev/null +++ b/audio/frame.go @@ -0,0 +1,56 @@ +package audio + +import ( + "math" + + "github.com/mokiat/gomath/sprec" +) + +// Frame represents a PCM frame with left and right channel data. +type Frame struct { + + // Left is the left channel sample value. + Left float32 + + // Right is the right channel sample value. + Right float32 +} + +// Resample resamples the given audio frames from one sample rate to another. +func Resample(frames []Frame, fromRate int, toRate int) []Frame { + if (fromRate == toRate) || (len(frames) == 0) { + return frames + } + if fromRate <= 0 || toRate <= 0 { + panic("invalid sample rate") + } + oldLength := len(frames) + + scale := float64(toRate) / float64(fromRate) + newLength := int(float64(len(frames))*scale + 0.5) + if newLength <= 0 { + return nil + } + if newLength == 1 { + return []Frame{frames[0]} + } + + result := make([]Frame, newLength) + step := float64(oldLength-1) / float64(newLength-1) + for i := range newLength { + srcPosition, srcFraction := math.Modf(float64(i) * step) + srcIndexPrev := min(int(srcPosition), oldLength-1) + srcIndexNext := min(srcIndexPrev+1, oldLength-1) + if srcIndexPrev == srcIndexNext { + result[i] = frames[srcIndexPrev] + } else { + prev := frames[srcIndexPrev] + next := frames[srcIndexNext] + result[i] = Frame{ + Left: sprec.Mix(prev.Left, next.Left, float32(srcFraction)), + Right: sprec.Mix(prev.Right, next.Right, float32(srcFraction)), + } + } + } + return result +} diff --git a/audio/media.go b/audio/media.go index 3d287528..91d8621a 100644 --- a/audio/media.go +++ b/audio/media.go @@ -2,29 +2,14 @@ package audio import "time" -// MediaDataType indicates the type of media data contained in a data block. -type MediaDataType int8 +// MediaData represents the raw audio data and its associated metadata. +type MediaData struct { -const ( - // MediaDataTypeAuto indicates that the media data type should be - // automatically detected based on the data. - MediaDataTypeAuto MediaDataType = iota + // Frames contains the decoded audio frames. + Frames []Frame - // MediaDataTypeWAV indicates that the media data is in WAV format. - MediaDataTypeWAV - - // MediaDataTypeMP3 indicates that the media data is in MP3 format. - MediaDataTypeMP3 -) - -// MediaInfo contains the necessary information to create a Media. -type MediaInfo struct { - - // Data is the raw media data. - Data []byte - - // DataType indicates the type of media data. - DataType MediaDataType + // SampleRate is the sample rate of the audio data. + SampleRate int } // Media represents a playable audio sequence. diff --git a/audio/mp3/decoder.go b/audio/mp3/decoder.go new file mode 100644 index 00000000..aeb025dd --- /dev/null +++ b/audio/mp3/decoder.go @@ -0,0 +1,61 @@ +package mp3 + +import ( + "io" + "math" + + "github.com/hajimehoshi/go-mp3" + "github.com/mokiat/gblob" + "github.com/mokiat/lacking/audio" +) + +func init() { + magics := []string{ + "ID3", // ID3v2 + } + audio.RegisterFormat("mp3", magics, Decode) +} + +// Decode decodes MP3 data from the provided reader and returns the decoded +// audio frames. +func Decode(in io.Reader) (audio.MediaData, error) { + decoder, err := mp3.NewDecoder(in) + if err != nil { + return audio.MediaData{}, err + } + + // TODO: There must be a faster and cheaper way to do this. + // 1. The decoder has overhead from being able to Seek. If an implementation + // is used/written that doesn't support seeking, some overhead can be avoided. + // 2. It might be possible to decode directly via a LittleEndian decoder + // without having to read the entire data into memory. + data, err := io.ReadAll(decoder) + if err != nil { + return audio.MediaData{}, err + } + buffer := gblob.LittleEndianBlock(data) + + length := len(data) / 4 + frames := make([]audio.Frame, length) + for i := range length { + leftInt16 := buffer.Int16(i*4 + 0) + rightInt16 := buffer.Int16(i*4 + 2) + frames[i] = audio.Frame{ + Left: int16ToFloat32(leftInt16), + Right: int16ToFloat32(rightInt16), + } + } + + return audio.MediaData{ + Frames: frames, + SampleRate: decoder.SampleRate(), + }, nil +} + +func int16ToFloat32(value int16) float32 { + if value >= 0 { + return float32(value) / float32(math.MaxInt16) + } else { + return -float32(value) / float32(math.MinInt16) + } +} diff --git a/audio/nop.go b/audio/nop.go index a19c1db2..9e76a3cc 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -25,11 +25,7 @@ func (a *nopAPI) SampleRate() int { return 44100 } -func (a *nopAPI) CreateMedia(samples []Sample) Media { - return NewNopMedia() -} - -func (a *nopAPI) ParseMedia(info MediaInfo) Media { +func (a *nopAPI) CreateMedia(data MediaData) Media { return NewNopMedia() } diff --git a/audio/sample.go b/audio/sample.go deleted file mode 100644 index 40f96dcc..00000000 --- a/audio/sample.go +++ /dev/null @@ -1,68 +0,0 @@ -package audio - -import ( - "math" - - "github.com/mokiat/gomath/sprec" -) - -// Sample represents a single audio sample with left and right channel data. -type Sample struct { - - // Left is the left channel sample value. - Left float32 - - // Right is the right channel sample value. - Right float32 -} - -// SampleCount calculates the number of samples for a given duration in seconds -// given the used sample rate. -func SampleCount(seconds float32, sampleRate int) int { - return int(float32(sampleRate) * seconds) -} - -// Seconds calculates the duration in seconds for a given number of samples -// and sample rate. -func Seconds(sampleCount, sampleRate int) float32 { - return float32(sampleCount) / float32(sampleRate) -} - -// Resample resamples the given audio samples from one sample rate to another. -func Resample(samples []Sample, fromRate int, toRate int) []Sample { - if (fromRate == toRate) || (len(samples) == 0) { - return samples - } - if fromRate <= 0 || toRate <= 0 { - panic("invalid sample rate") - } - oldLength := len(samples) - - scale := float64(toRate) / float64(fromRate) - newLength := int(float64(len(samples))*scale + 0.5) - if newLength <= 0 { - return nil - } - if newLength == 1 { - return []Sample{samples[0]} - } - - result := make([]Sample, newLength) - step := float64(oldLength-1) / float64(newLength-1) - for i := range newLength { - srcPosition, srcFraction := math.Modf(float64(i) * step) - srcIndexPrev := min(int(srcPosition), oldLength-1) - srcIndexNext := min(srcIndexPrev+1, oldLength-1) - if srcIndexPrev == srcIndexNext { - result[i] = samples[srcIndexPrev] - } else { - prev := samples[srcIndexPrev] - next := samples[srcIndexNext] - result[i] = Sample{ - Left: sprec.Mix(prev.Left, next.Left, float32(srcFraction)), - Right: sprec.Mix(prev.Right, next.Right, float32(srcFraction)), - } - } - } - return result -} diff --git a/audio/util.go b/audio/util.go index 7da23bd7..1677fe8e 100644 --- a/audio/util.go +++ b/audio/util.go @@ -11,3 +11,15 @@ func DBToGain(db float32) float32 { func GainToDB(gain float32) float32 { return float32(20.0 * math.Log10(float64(gain))) } + +// SampleCount calculates the number of samples for a given duration in seconds +// given the used sample rate. +func SampleCount(seconds float32, sampleRate int) int { + return int(float32(sampleRate) * seconds) +} + +// Seconds calculates the duration in seconds for a given number of samples +// and sample rate. +func Seconds(sampleCount, sampleRate int) float32 { + return float32(sampleCount) / float32(sampleRate) +} diff --git a/audio/wav/decoder.go b/audio/wav/decoder.go new file mode 100644 index 00000000..b981c883 --- /dev/null +++ b/audio/wav/decoder.go @@ -0,0 +1,60 @@ +package wav + +import ( + "bytes" + "io" + + "github.com/go-audio/wav" + "github.com/mokiat/lacking/audio" +) + +func init() { + magics := []string{ + "RIFF", // RIFF header + "WAVE", // WAVE header + } + audio.RegisterFormat("wav", magics, Decode) +} + +// Decode decodes WAV data from the provided reader and returns the decoded +// audio frames. +func Decode(in io.Reader) (audio.MediaData, error) { + raw, err := io.ReadAll(in) + if err != nil { + return audio.MediaData{}, err + } + + decoder := wav.NewDecoder(bytes.NewReader(raw)) + buffer, err := decoder.FullPCMBuffer() + if err != nil { + return audio.MediaData{}, err + } + flBuffer := buffer.AsFloat32Buffer() + + length := flBuffer.NumFrames() + frames := make([]audio.Frame, length) + if buffer.Format.NumChannels == 1 { + for i := range length { + value := flBuffer.Data[i] + frames[i] = audio.Frame{ + Left: value, + Right: value, + } + } + } + if buffer.Format.NumChannels > 1 { + offset := 0 + for i := range length { + frames[i] = audio.Frame{ + Left: flBuffer.Data[offset+0], + Right: flBuffer.Data[offset+1], + } + offset += buffer.Format.NumChannels + } + } + + return audio.MediaData{ + Frames: frames, + SampleRate: buffer.Format.SampleRate, + }, nil +} diff --git a/go.mod b/go.mod index 9fc7d994..04c94359 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/mokiat/lacking go 1.26 require ( + github.com/go-audio/wav v1.1.0 github.com/google/uuid v1.6.0 + github.com/hajimehoshi/go-mp3 v0.3.4 github.com/mdouchement/hdr v0.2.4 github.com/mokiat/gblob v0.6.0 github.com/mokiat/goexr v0.1.0 @@ -20,6 +22,8 @@ require ( require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/go-audio/audio v1.0.0 // indirect + github.com/go-audio/riff v1.0.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/go.sum b/go.sum index 4724d510..f40b4d65 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,12 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= +github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -24,6 +30,9 @@ github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -83,6 +92,7 @@ golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/ui/resource_manager.go b/ui/resource_manager.go index 2c38c54c..82d4ec09 100644 --- a/ui/resource_manager.go +++ b/ui/resource_manager.go @@ -3,12 +3,13 @@ package ui import ( "fmt" "image" - "io" - _ "image/jpeg" _ "image/png" + "io" "github.com/mokiat/lacking/audio" + _ "github.com/mokiat/lacking/audio/mp3" + _ "github.com/mokiat/lacking/audio/wav" "github.com/mokiat/lacking/resource" "golang.org/x/image/font/opentype" ) @@ -105,6 +106,11 @@ func (m *resourceManager) OpenFontCollection(uri string) (*FontCollection, error return m.CreateFontCollection(otCollection) } +func (m *resourceManager) CreateSound(data audio.MediaData) *Sound { + media := m.audioAPI.CreateMedia(data) + return newSound(m.audioAPI, media) +} + func (m *resourceManager) OpenSound(uri string) (*Sound, error) { in, err := m.locator.Open(uri) if err != nil { @@ -112,14 +118,9 @@ func (m *resourceManager) OpenSound(uri string) (*Sound, error) { } defer in.Close() - data, err := io.ReadAll(in) + data, _, err := audio.Decode(in) if err != nil { - return nil, fmt.Errorf("error reading resource: %w", err) + return nil, fmt.Errorf("error decoding audio: %w", err) } - - media := m.audioAPI.ParseMedia(audio.MediaInfo{ - Data: data, - DataType: audio.MediaDataTypeAuto, - }) - return newSound(m.audioAPI, media), nil + return m.CreateSound(data), nil } From f994e032152a93069c1eb66c4e7da981cf0219f0 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 27 May 2026 23:06:28 +0300 Subject: [PATCH 44/59] audio: minor fixes and adjustments --- audio/api.go | 7 ++++--- audio/decode.go | 6 +----- audio/mp3/decoder.go | 2 +- audio/nop.go | 4 ++-- audio/wav/decoder.go | 34 +++++++++++++++++----------------- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/audio/api.go b/audio/api.go index 901b7447..d7d6b1ba 100644 --- a/audio/api.go +++ b/audio/api.go @@ -27,7 +27,7 @@ type API interface { // CreatePlaybackNode creates a new playback node for the specified media. // // It is safe to delete the media after creating the playback node. - CreatePlaybackNode(media Media, loop bool) PlaybackNode + CreatePlaybackNode(media Media) PlaybackNode // CreateOscillatorNode creates a new oscillator node. CreateOscillatorNode() OscillatorNode @@ -35,7 +35,7 @@ type API interface { // CreateGainNode creates a new gain node. CreateGainNode() GainNode - // CreatePan creates a new pan node. + // CreatePanNode creates a new pan node. CreatePanNode() PanNode // CreateSpatialNode creates a new spatial audio node. @@ -77,6 +77,7 @@ type API interface { // Play plays the specified media as soon as possible. // - // TODO: REMOVE THIS!!!! + // Deprecated: Use [API.CreatePlaybackNode] and [PlaybackNode.Play] instead + // for more control over playback. Play(media Media, info PlayInfo) Playback } diff --git a/audio/decode.go b/audio/decode.go index 9f83f09d..6ffcf542 100644 --- a/audio/decode.go +++ b/audio/decode.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "errors" - "fmt" "io" ) @@ -35,10 +34,7 @@ func findDecoder(r *bufio.Reader) (DecodeFunc, string, error) { for _, f := range registeredFormats { count := len(f.magic) actualMagic, err := r.Peek(count) - if err != nil { - return nil, "", fmt.Errorf("error peeking magic prefix: %w", err) - } - if bytes.Equal(f.magic, actualMagic) { + if err == nil && bytes.Equal(f.magic, actualMagic) { return f.decode, f.name, nil } } diff --git a/audio/mp3/decoder.go b/audio/mp3/decoder.go index aeb025dd..8852c012 100644 --- a/audio/mp3/decoder.go +++ b/audio/mp3/decoder.go @@ -11,7 +11,7 @@ import ( func init() { magics := []string{ - "ID3", // ID3v2 + "ID3", } audio.RegisterFormat("mp3", magics, Decode) } diff --git a/audio/nop.go b/audio/nop.go index 9e76a3cc..e64a146b 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -37,7 +37,7 @@ func (a *nopAPI) SpatialListener() SpatialListener { return a.listener } -func (a *nopAPI) CreatePlaybackNode(media Media, loop bool) PlaybackNode { +func (a *nopAPI) CreatePlaybackNode(media Media) PlaybackNode { return &nopPlaybackNode{} } @@ -121,7 +121,7 @@ func NewNopMedia() Media { type nopMedia struct{} func (m *nopMedia) Length() time.Duration { - return time.Millisecond + return 0 } func (m *nopMedia) Delete() {} diff --git a/audio/wav/decoder.go b/audio/wav/decoder.go index b981c883..aca09725 100644 --- a/audio/wav/decoder.go +++ b/audio/wav/decoder.go @@ -10,8 +10,7 @@ import ( func init() { magics := []string{ - "RIFF", // RIFF header - "WAVE", // WAVE header + "RIFF", } audio.RegisterFormat("wav", magics, Decode) } @@ -33,23 +32,24 @@ func Decode(in io.Reader) (audio.MediaData, error) { length := flBuffer.NumFrames() frames := make([]audio.Frame, length) - if buffer.Format.NumChannels == 1 { - for i := range length { - value := flBuffer.Data[i] - frames[i] = audio.Frame{ - Left: value, - Right: value, + if buffer.Format.NumChannels > 0 { + if buffer.Format.NumChannels == 1 { + for i := range length { + value := flBuffer.Data[i] + frames[i] = audio.Frame{ + Left: value, + Right: value, + } } - } - } - if buffer.Format.NumChannels > 1 { - offset := 0 - for i := range length { - frames[i] = audio.Frame{ - Left: flBuffer.Data[offset+0], - Right: flBuffer.Data[offset+1], + } else { + offset := 0 + for i := range length { + frames[i] = audio.Frame{ + Left: flBuffer.Data[offset+0], + Right: flBuffer.Data[offset+1], + } + offset += buffer.Format.NumChannels } - offset += buffer.Format.NumChannels } } From cba3ee7f3b92897f8a1aa77c77866e4fc0a097f0 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 27 May 2026 23:18:21 +0300 Subject: [PATCH 45/59] audio: godoc improvements --- audio/decode.go | 7 ++++--- audio/format.go | 5 +++++ audio/node.go | 6 ++++-- audio/nop.go | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/audio/decode.go b/audio/decode.go index 6ffcf542..0c714522 100644 --- a/audio/decode.go +++ b/audio/decode.go @@ -11,10 +11,11 @@ import ( // returns a slice of audio frames. type DecodeFunc func(io.Reader) (MediaData, error) -// Decode decodes an audio that has been encoded in a registered format. +// Decode decodes audio data encoded in a registered format. // -// If the format of the audio data cannot be determined, [errors.ErrUnsupported] -// is returned. +// It returns the decoded [MediaData], the name of the detected format (as +// registered via [RegisterFormat]), and any error encountered. If the format +// cannot be determined, [errors.ErrUnsupported] is returned. func Decode(r io.Reader) (MediaData, string, error) { in := bufio.NewReader(r) diff --git a/audio/format.go b/audio/format.go index b44f3622..6fc08734 100644 --- a/audio/format.go +++ b/audio/format.go @@ -5,6 +5,11 @@ import ( ) // RegisterFormat registers an audio format for use by [Decode]. +// +// The name parameter is a human-readable identifier for the format (e.g. +// "mp3", "wav"). The magics parameter is a list of magic byte prefixes that +// identify the format in raw data. The decode parameter is the function that +// will be called to decode data matching any of the magic prefixes. func RegisterFormat(name string, magics []string, decode DecodeFunc) { registryMu.Lock() defer registryMu.Unlock() diff --git a/audio/node.go b/audio/node.go index 2bfc3f6c..2b3555cd 100644 --- a/audio/node.go +++ b/audio/node.go @@ -335,8 +335,10 @@ type CompressorNode interface { SetThreshold(threshold float32) } -// ConnectorNode represents a no-op audio node that can be used to connect -// other nodes together without affecting the audio signal. +// ConnectorNode represents a pass-through audio node that forwards its input +// signal unchanged. It is useful as a named connection point in a larger node +// graph, allowing portions of the graph to be rewired without touching every +// connected source. type ConnectorNode interface { UserNode } diff --git a/audio/nop.go b/audio/nop.go index e64a146b..4675b907 100644 --- a/audio/nop.go +++ b/audio/nop.go @@ -114,6 +114,7 @@ func (a *nopAPI) Play(media Media, info PlayInfo) Playback { return &nopPlayback{} } +// NewNopMedia returns a Media that does nothing. func NewNopMedia() Media { return &nopMedia{} } From 2995eae14f291ed9deffbb1b8b7ba3a7373ec91f Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 3 Jun 2026 22:24:53 +0300 Subject: [PATCH 46/59] audio: pivot towards mid-level audio api --- core/audio/api.go | 33 ++++++++++++ core/audio/bus.go | 42 ++++++++++++++++ core/audio/filter.go | 102 ++++++++++++++++++++++++++++++++++++++ core/audio/frame.go | 56 +++++++++++++++++++++ core/audio/media.go | 24 +++++++++ core/audio/mp3/decoder.go | 54 ++++++++++++++++++++ core/audio/spatial.go | 69 ++++++++++++++++++++++++++ core/audio/wav/decoder.go | 53 ++++++++++++++++++++ 8 files changed, 433 insertions(+) create mode 100644 core/audio/api.go create mode 100644 core/audio/bus.go create mode 100644 core/audio/filter.go create mode 100644 core/audio/frame.go create mode 100644 core/audio/media.go create mode 100644 core/audio/mp3/decoder.go create mode 100644 core/audio/spatial.go create mode 100644 core/audio/wav/decoder.go diff --git a/core/audio/api.go b/core/audio/api.go new file mode 100644 index 00000000..4c4dcd38 --- /dev/null +++ b/core/audio/api.go @@ -0,0 +1,33 @@ +package audio + +// API abstracts the underlying audio system, providing a consistent interface for audio manipulation and playback. +// +// All methods must be called from the UI thread. +type API interface { + + // CreateBus creates a new flat audio bus. + CreateBus(settings BusSettings) Bus + + // MasterBus returns the master bus for the audio system. + MasterBus() MasterBus + + // SpatialListener returns the spatial listener used for 3D audio. + SpatialListener() SpatialListener +} + +// MasterBus represents the master bus for the audio system, controlling the overall output of all audio. +type MasterBus interface { + + // Gain returns the master gain for the audio system. + // + // Default value is 1.0. + Gain() float32 + + // SetGain sets the master gain for the audio system. + // + // The value must be non-negative. + SetGain(gain float32) + + // Compression returns the global compression controls for the audio system. + Compression() Compression +} diff --git a/core/audio/bus.go b/core/audio/bus.go new file mode 100644 index 00000000..6b2c4697 --- /dev/null +++ b/core/audio/bus.go @@ -0,0 +1,42 @@ +package audio + +// Bus represents a flat audio bus that can be used to group sound sources together for collective control. +type Bus interface { + + // Gain returns the current gain of the bus. + // + // Default is 1.0, which means no change in volume. + Gain() float32 + + // SetGain sets the gain of the bus. + SetGain(gain float32) + + // Reverb returns the reverb controls of the bus. + // + // If the bus was not created with reverb enabled, this will return nil. + Reverb() Reverb + + // Compression returns the compression controls of the bus. + // + // If the bus was not created with compression enabled, this will return nil. + Compression() Compression + + // Release releases any resources associated with the bus. + // + // All attached sound sources will be stopped. + Release() +} + +// BusSettings represents the settings for creating a new audio bus. +type BusSettings struct { + + // UseReverb indicates whether to enable reverb on the bus. + // + // Default is false. + UseReverb bool + + // UseCompression indicates whether to enable compression on the bus. + // + // Default is false. + UseCompression bool +} diff --git a/core/audio/filter.go b/core/audio/filter.go new file mode 100644 index 00000000..85e585f7 --- /dev/null +++ b/core/audio/filter.go @@ -0,0 +1,102 @@ +package audio + +// Reverb represents the settings for audio reverb on a bus. +type Reverb interface { + + // RoomSize returns the size of the virtual room for the reverb effect. + // + // The value is in the range [0.0, 1.0]. Default value is 0.3. + RoomSize() float32 + + // SetRoomSize sets the size of the virtual room for the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetRoomSize(size float32) + + // Damping returns the damping factor of the reverb effect. + // + // Higher values cause high frequencies to decay faster, simulating + // absorptive surfaces. + // + // Default value is 0.5. + Damping() float32 + + // SetDamping sets the damping factor of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetDamping(damping float32) + + // Dry returns the dry level of the reverb effect. + // + // Default value is 1.0. + Dry() float32 + + // SetDry sets the dry level of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetDry(dry float32) + + // Wet returns the wet level of the reverb effect. + // + // Default value is 0.5. + Wet() float32 + + // SetWet sets the wet level of the reverb effect. + // + // The value will be clamped to the range [0.0, 1.0]. + SetWet(wet float32) +} + +// Compression represents the settings for audio compression on a bus. +type Compression interface { + + // Attack returns the attack time in seconds. + // + // Default value is 0.003 seconds. + Attack() float32 + + // SetAttack sets the attack time in seconds. + // + // The value will be clamped to the range [0.0, 1.0]. + SetAttack(attack float32) + + // Release returns the release time in seconds. + // + // Default value is 0.25 seconds. + Release() float32 + + // SetRelease sets the release time in seconds. + // + // The value will be clamped to the range [0.0, 1.0]. + SetRelease(release float32) + + // Ratio returns the compression ratio. + // + // Default value is 12.0. + Ratio() float32 + + // SetRatio sets the compression ratio. + // + // The value will be clamped to the range [1.0, 20.0]. + SetRatio(ratio float32) + + // Knee returns the knee width in decibels. + // + // Default value is 30.0 dB. + Knee() float32 + + // SetKnee sets the knee width in decibels. + // + // The value will be clamped to the range [0.0, 40.0]. + SetKnee(knee float32) + + // Threshold returns the threshold level in decibels. + // + // Default value is -24.0 dB. + Threshold() float32 + + // SetThreshold sets the threshold level in decibels. + // + // The value will be clamped to the range [-100.0, 0.0]. + SetThreshold(threshold float32) +} diff --git a/core/audio/frame.go b/core/audio/frame.go new file mode 100644 index 00000000..a4881655 --- /dev/null +++ b/core/audio/frame.go @@ -0,0 +1,56 @@ +package audio + +import ( + "math" + + "github.com/mokiat/gomath/sprec" +) + +// Frame represents a PCM frame with left and right channel data. +type Frame struct { + + // Left is the left channel sample value. + Left float32 + + // Right is the right channel sample value. + Right float32 +} + +// Resample resamples the given audio frames from one sample rate to another. +func Resample(frames []Frame, fromRate int, toRate int) []Frame { + if (fromRate == toRate) || (len(frames) == 0) { + return frames + } + if fromRate <= 0 || toRate <= 0 { + panic("invalid sample rate") + } + oldLength := len(frames) + + scale := float64(toRate) / float64(fromRate) + newLength := int(float64(len(frames))*scale + 0.5) + if newLength <= 0 { + return nil + } + if newLength == 1 { + return []Frame{frames[0]} + } + + result := make([]Frame, newLength) + step := float64(oldLength-1) / float64(newLength-1) + for i := range newLength { + srcPosition, srcFraction := math.Modf(float64(i) * step) + srcIndexPrev := min(int(srcPosition), oldLength-1) + srcIndexNext := min(srcIndexPrev+1, oldLength-1) + if srcIndexPrev == srcIndexNext { + result[i] = frames[srcIndexPrev] + } else { + prev := frames[srcIndexPrev] + next := frames[srcIndexNext] + result[i] = Frame{ + Left: sprec.Mix(prev.Left, next.Left, float32(srcFraction)), + Right: sprec.Mix(prev.Right, next.Right, float32(srcFraction)), + } + } + } + return result +} diff --git a/core/audio/media.go b/core/audio/media.go new file mode 100644 index 00000000..3394c0d1 --- /dev/null +++ b/core/audio/media.go @@ -0,0 +1,24 @@ +package audio + +// MediaData represents the raw audio data and its associated metadata. +type MediaData struct { + + // Frames contains the decoded audio frames. + Frames []Frame + + // SampleRate is the sample rate of the audio data. + SampleRate int +} + +// Media represents an audio media object that can be played back or manipulated. +type Media interface { + + // Length returns the duration of the media in seconds. + Length() float32 + + // Release releases any resources associated with the media. After calling this method, + // the media should not be used anymore. + // + // Existing playback is not affected. + Release() +} diff --git a/core/audio/mp3/decoder.go b/core/audio/mp3/decoder.go new file mode 100644 index 00000000..914b2fa2 --- /dev/null +++ b/core/audio/mp3/decoder.go @@ -0,0 +1,54 @@ +package mp3 + +import ( + "io" + "math" + + "github.com/hajimehoshi/go-mp3" + "github.com/mokiat/gblob" + "github.com/mokiat/lacking/core/audio" +) + +// Decode decodes MP3 data from the provided reader and returns the decoded +// audio frames. +func Decode(in io.Reader) (audio.MediaData, error) { + decoder, err := mp3.NewDecoder(in) + if err != nil { + return audio.MediaData{}, err + } + + // TODO: There must be a faster and cheaper way to do this. + // 1. The decoder has overhead from being able to Seek. If an implementation + // is used/written that doesn't support seeking, some overhead can be avoided. + // 2. It might be possible to decode directly via a LittleEndian decoder + // without having to read the entire data into memory. + data, err := io.ReadAll(decoder) + if err != nil { + return audio.MediaData{}, err + } + buffer := gblob.LittleEndianBlock(data) + + length := len(data) / 4 + frames := make([]audio.Frame, length) + for i := range length { + leftInt16 := buffer.Int16(i*4 + 0) + rightInt16 := buffer.Int16(i*4 + 2) + frames[i] = audio.Frame{ + Left: int16ToFloat32(leftInt16), + Right: int16ToFloat32(rightInt16), + } + } + + return audio.MediaData{ + Frames: frames, + SampleRate: decoder.SampleRate(), + }, nil +} + +func int16ToFloat32(value int16) float32 { + if value >= 0 { + return float32(value) / float32(math.MaxInt16) + } else { + return -float32(value) / float32(math.MinInt16) + } +} diff --git a/core/audio/spatial.go b/core/audio/spatial.go new file mode 100644 index 00000000..d40aeb06 --- /dev/null +++ b/core/audio/spatial.go @@ -0,0 +1,69 @@ +package audio + +import "github.com/mokiat/gomath/sprec" + +// SpatialListener represents a listener in 3D space for spatial audio. +type SpatialListener interface { + + // Position returns the 3D position of the listener. + Position() sprec.Vec3 + + // SetPosition sets the 3D position of the listener. + SetPosition(position sprec.Vec3) + + // Rotation returns the orientation of the listener as a quaternion. + Rotation() sprec.Quat + + // SetRotation sets the orientation of the listener as a quaternion. + SetRotation(rotation sprec.Quat) + + // Velocity returns the velocity of the listener in 3D space in meters per second. + Velocity() sprec.Vec3 + + // SetVelocity sets the velocity of the listener in 3D space in meters per second. + SetVelocity(velocity sprec.Vec3) +} + +// SpatialEmitter represents an emitter in 3D space for spatial audio. +type SpatialEmitter interface { + + // Position returns the 3D position of the emitter. + Position() sprec.Vec3 + + // SetPosition sets the 3D position of the emitter. + SetPosition(position sprec.Vec3) + + // Rotation returns the orientation of the emitter as a quaternion. + Rotation() sprec.Quat + + // SetRotation sets the orientation of the emitter as a quaternion. + SetRotation(rotation sprec.Quat) + + // Velocity returns the velocity of the emitter in 3D space in meters per second. + Velocity() sprec.Vec3 + + // SetVelocity sets the velocity of the emitter in 3D space in meters per second. + SetVelocity(velocity sprec.Vec3) + + // InnerConeAngle returns the inner cone angle of the emitter. + InnerConeAngle() sprec.Angle + + // SetInnerConeAngle sets the inner cone angle of the emitter. + SetInnerConeAngle(angle sprec.Angle) + + // OuterConeAngle returns the outer cone angle of the emitter. + // + // Default is 360 degrees, which means no directional attenuation. + OuterConeAngle() sprec.Angle + + // SetOuterConeAngle sets the outer cone angle of the emitter. + SetOuterConeAngle(angle sprec.Angle) + + // OuterConeGain returns the gain applied to the emitter when the listener is outside the outer cone. + // + // Default is 0.0, which means the emitter is silent when the listener is outside the outer cone. + OuterConeGain() float32 + + // SetOuterConeGain sets the gain applied to the emitter when the listener is outside the outer cone. + SetOuterConeGain(gain float32) +} diff --git a/core/audio/wav/decoder.go b/core/audio/wav/decoder.go new file mode 100644 index 00000000..d70ba487 --- /dev/null +++ b/core/audio/wav/decoder.go @@ -0,0 +1,53 @@ +package wav + +import ( + "bytes" + "io" + + "github.com/go-audio/wav" + "github.com/mokiat/lacking/core/audio" +) + +// Decode decodes WAV data from the provided reader and returns the decoded +// audio frames. +func Decode(in io.Reader) (audio.MediaData, error) { + raw, err := io.ReadAll(in) + if err != nil { + return audio.MediaData{}, err + } + + decoder := wav.NewDecoder(bytes.NewReader(raw)) + buffer, err := decoder.FullPCMBuffer() + if err != nil { + return audio.MediaData{}, err + } + flBuffer := buffer.AsFloat32Buffer() + + length := flBuffer.NumFrames() + frames := make([]audio.Frame, length) + if buffer.Format.NumChannels > 0 { + if buffer.Format.NumChannels == 1 { + for i := range length { + value := flBuffer.Data[i] + frames[i] = audio.Frame{ + Left: value, + Right: value, + } + } + } else { + offset := 0 + for i := range length { + frames[i] = audio.Frame{ + Left: flBuffer.Data[offset+0], + Right: flBuffer.Data[offset+1], + } + offset += buffer.Format.NumChannels + } + } + } + + return audio.MediaData{ + Frames: frames, + SampleRate: buffer.Format.SampleRate, + }, nil +} From b3adc5d2e5fb560d307f9fa1160e85a9cfad8e08 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 3 Jun 2026 23:02:41 +0300 Subject: [PATCH 47/59] audio: more additions to new audio api --- core/audio/api.go | 9 +++++ core/audio/filter.go | 12 +++++++ core/audio/playback.go | 81 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 core/audio/playback.go diff --git a/core/audio/api.go b/core/audio/api.go index 4c4dcd38..deab12a2 100644 --- a/core/audio/api.go +++ b/core/audio/api.go @@ -5,9 +5,18 @@ package audio // All methods must be called from the UI thread. type API interface { + // CreateMedia creates a new media object from the provided media data. + CreateMedia(data MediaData) Media + // CreateBus creates a new flat audio bus. CreateBus(settings BusSettings) Bus + // CreatePlayback creates a new playback instance for the given media on the specified bus. + CreatePlayback(bus Bus, media Media, settings PlaybackSettings) Playback + + // CreateSpatialPlayback creates a new spatial playback instance for the given media on the specified bus. + CreateSpatialPlayback(bus Bus, media Media, settings PlaybackSettings) SpatialPlayback + // MasterBus returns the master bus for the audio system. MasterBus() MasterBus diff --git a/core/audio/filter.go b/core/audio/filter.go index 85e585f7..e8a64a1b 100644 --- a/core/audio/filter.go +++ b/core/audio/filter.go @@ -100,3 +100,15 @@ type Compression interface { // The value will be clamped to the range [-100.0, 0.0]. SetThreshold(threshold float32) } + +// FrequencyFilter represents a simple frequency filter. +type FrequencyFilter interface { + + // Frequency returns the cutoff frequency of the filter in hertz. + Frequency() float32 + + // SetFrequency sets the cutoff frequency of the filter in hertz. + // + // The value must be positive. + SetFrequency(frequency float32) +} diff --git a/core/audio/playback.go b/core/audio/playback.go new file mode 100644 index 00000000..30e35d68 --- /dev/null +++ b/core/audio/playback.go @@ -0,0 +1,81 @@ +package audio + +// Playback represents an instance of a media being played on a bus. It allows control over the playback state of +// the media. +type Playback interface { + + // Start begins playback of the media. If the media is already playing, this method has no effect. + // + // The at parameter specifies the starting point in seconds from the beginning of the media. If at is negative or + // greater than the length of the media, it will be clamped to the valid range. + Start(at float32) + + // Stop halts playback of the media. If the media is not playing, this method has no effect. + Stop() + + // Pause temporarily halts playback of the media. If the media is not playing, this method has no effect. + Pause() + + // Resume continues playback of the media if it was paused. If the media is not paused, this method has no effect. + Resume() + + // Looping returns true if the media is set to loop, false otherwise. + Looping() bool + + // SetLooping sets whether the media should loop when it reaches the end. + SetLooping(loop bool) + + // LoopStart returns the starting point in seconds from the beginning of the media where looping should occur. + // + // Default value is 0.0 seconds. + LoopStart() float32 + + // SetLoopStart sets the starting point in seconds from the beginning of the media where looping should occur. + SetLoopStart(loopStart float32) + + // LoopEnd returns the ending point in seconds from the beginning of the media where looping should occur. + // + // Default value is the length of the media. + LoopEnd() float32 + + // SetLoopEnd sets the ending point in seconds from the beginning of the media where looping should occur. + SetLoopEnd(loopEnd float32) + + // Playing returns true if the media is currently playing, false otherwise. + Playing() bool + + // PlaybackRate returns the current playback rate of the media. + PlaybackRate() float32 + + // SetPlaybackRate sets the playback rate of the media. + SetPlaybackRate(rate float32) + + // LowPassFilter returns the low-pass filter applied to this playback, if any. If no low-pass filter is applied, + // this method returns nil. + LowPassFilter() FrequencyFilter + + // HighPassFilter returns the high-pass filter applied to this playback, if any. If no high-pass filter is applied, + // this method returns nil. + HighPassFilter() FrequencyFilter + + // Release releases any resources associated with this playback instance. After calling this method, the playback + // should not be used anymore. + Release() +} + +// SpatialPlayback represents a playback instance that also has spatial properties, allowing it to be positioned +// and oriented in 3D space for spatial audio effects. +type SpatialPlayback interface { + Playback + SpatialEmitter +} + +// PlaybackSettings represents the settings for creating a playback instance. +type PlaybackSettings struct { + + // UseLowPassFilter indicates whether a low-pass filter should be applied to the playback. + UseLowPassFilter bool + + // UseHighPassFilter indicates whether a high-pass filter should be applied to the playback. + UseHighPassFilter bool +} From dcf0fbc1a8613535828f4770c37746e61d662ff2 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Wed, 3 Jun 2026 23:20:16 +0300 Subject: [PATCH 48/59] audio: further enhancements --- core/audio/bus.go | 6 ++++++ core/audio/playback.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/core/audio/bus.go b/core/audio/bus.go index 6b2c4697..8015961a 100644 --- a/core/audio/bus.go +++ b/core/audio/bus.go @@ -21,6 +21,12 @@ type Bus interface { // If the bus was not created with compression enabled, this will return nil. Compression() Compression + // Pause pauses all sound sources attached to the bus. If the bus is already paused, this method has no effect. + Pause() + + // Resume resumes all sound sources attached to the bus if they were paused. If the bus is not paused, this method has no effect. + Resume() + // Release releases any resources associated with the bus. // // All attached sound sources will be stopped. diff --git a/core/audio/playback.go b/core/audio/playback.go index 30e35d68..5d4dfc4c 100644 --- a/core/audio/playback.go +++ b/core/audio/playback.go @@ -44,6 +44,14 @@ type Playback interface { // Playing returns true if the media is currently playing, false otherwise. Playing() bool + // Gain returns the current gain of the playback. + // + // Default value is 1.0, which means no change in volume. + Gain() float32 + + // SetGain sets the gain of the playback. + SetGain(gain float32) + // PlaybackRate returns the current playback rate of the media. PlaybackRate() float32 @@ -58,6 +66,14 @@ type Playback interface { // this method returns nil. HighPassFilter() FrequencyFilter + // SetOnFinished sets a callback function that will be called when the media finishes playing naturally, + // i.e. when it reaches the end and is not set to loop. It will not be called if playback is stopped + // via Stop() or paused via Pause(), nor on each loop iteration. + // + // If looping is disabled (via SetLooping) while the media is playing, and the media subsequently + // reaches its end, the callback will be called. + SetOnFinished(onFinished func()) + // Release releases any resources associated with this playback instance. After calling this method, the playback // should not be used anymore. Release() @@ -73,6 +89,9 @@ type SpatialPlayback interface { // PlaybackSettings represents the settings for creating a playback instance. type PlaybackSettings struct { + // FireAndForget indicates whether the playback should automatically release its resources after it finishes playing. + FireAndForget bool + // UseLowPassFilter indicates whether a low-pass filter should be applied to the playback. UseLowPassFilter bool From f930e49e686407125712b6f12e3e253674386445 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 6 Jun 2026 11:51:27 +0300 Subject: [PATCH 49/59] audio: add utility functions --- core/audio/bus.go | 10 +++++----- core/audio/util.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 core/audio/util.go diff --git a/core/audio/bus.go b/core/audio/bus.go index 8015961a..d8e86ffc 100644 --- a/core/audio/bus.go +++ b/core/audio/bus.go @@ -11,16 +11,16 @@ type Bus interface { // SetGain sets the gain of the bus. SetGain(gain float32) - // Reverb returns the reverb controls of the bus. - // - // If the bus was not created with reverb enabled, this will return nil. - Reverb() Reverb - // Compression returns the compression controls of the bus. // // If the bus was not created with compression enabled, this will return nil. Compression() Compression + // Reverb returns the reverb controls of the bus. + // + // If the bus was not created with reverb enabled, this will return nil. + Reverb() Reverb + // Pause pauses all sound sources attached to the bus. If the bus is already paused, this method has no effect. Pause() diff --git a/core/audio/util.go b/core/audio/util.go new file mode 100644 index 00000000..cec7a52e --- /dev/null +++ b/core/audio/util.go @@ -0,0 +1,25 @@ +package audio + +import "math" + +// DBToGain converts a decibel value to a gain value. +func DBToGain(db float32) float32 { + return float32(math.Pow(10.0, float64(db/20.0))) +} + +// GainToDB converts a gain value to a decibel value. +func GainToDB(gain float32) float32 { + return float32(20.0 * math.Log10(float64(max(0.0, gain)))) +} + +// SampleCount calculates the number of samples for a given duration in seconds +// given the used sample rate. +func SampleCount(seconds float32, sampleRate int) int { + return int(float64(sampleRate) * float64(seconds)) +} + +// Seconds calculates the duration in seconds for a given number of samples +// and sample rate. +func Seconds(sampleCount, sampleRate int) float32 { + return float32(sampleCount) / float32(sampleRate) +} From 50e8f6ff9fd09644ca7e889f2a4cb46cd3e90d59 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sat, 6 Jun 2026 12:21:15 +0300 Subject: [PATCH 50/59] audio: remove velocity aspect and leave doppler to higher-order code via playback rate --- core/audio/playback.go | 12 ++++++------ core/audio/spatial.go | 12 ------------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/core/audio/playback.go b/core/audio/playback.go index 5d4dfc4c..f21656a2 100644 --- a/core/audio/playback.go +++ b/core/audio/playback.go @@ -44,6 +44,12 @@ type Playback interface { // Playing returns true if the media is currently playing, false otherwise. Playing() bool + // PlaybackRate returns the current playback rate of the media. + PlaybackRate() float32 + + // SetPlaybackRate sets the playback rate of the media. + SetPlaybackRate(rate float32) + // Gain returns the current gain of the playback. // // Default value is 1.0, which means no change in volume. @@ -52,12 +58,6 @@ type Playback interface { // SetGain sets the gain of the playback. SetGain(gain float32) - // PlaybackRate returns the current playback rate of the media. - PlaybackRate() float32 - - // SetPlaybackRate sets the playback rate of the media. - SetPlaybackRate(rate float32) - // LowPassFilter returns the low-pass filter applied to this playback, if any. If no low-pass filter is applied, // this method returns nil. LowPassFilter() FrequencyFilter diff --git a/core/audio/spatial.go b/core/audio/spatial.go index d40aeb06..36b122f0 100644 --- a/core/audio/spatial.go +++ b/core/audio/spatial.go @@ -16,12 +16,6 @@ type SpatialListener interface { // SetRotation sets the orientation of the listener as a quaternion. SetRotation(rotation sprec.Quat) - - // Velocity returns the velocity of the listener in 3D space in meters per second. - Velocity() sprec.Vec3 - - // SetVelocity sets the velocity of the listener in 3D space in meters per second. - SetVelocity(velocity sprec.Vec3) } // SpatialEmitter represents an emitter in 3D space for spatial audio. @@ -39,12 +33,6 @@ type SpatialEmitter interface { // SetRotation sets the orientation of the emitter as a quaternion. SetRotation(rotation sprec.Quat) - // Velocity returns the velocity of the emitter in 3D space in meters per second. - Velocity() sprec.Vec3 - - // SetVelocity sets the velocity of the emitter in 3D space in meters per second. - SetVelocity(velocity sprec.Vec3) - // InnerConeAngle returns the inner cone angle of the emitter. InnerConeAngle() sprec.Angle From 0b36225df44523ead5220525028146ffa5c52992 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 01:18:17 +0300 Subject: [PATCH 51/59] audio: small api adjustments --- core/audio/media.go | 2 +- core/audio/playback.go | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/core/audio/media.go b/core/audio/media.go index 3394c0d1..8f284ac4 100644 --- a/core/audio/media.go +++ b/core/audio/media.go @@ -14,7 +14,7 @@ type MediaData struct { type Media interface { // Length returns the duration of the media in seconds. - Length() float32 + Length() float64 // Release releases any resources associated with the media. After calling this method, // the media should not be used anymore. diff --git a/core/audio/playback.go b/core/audio/playback.go index f21656a2..e8481408 100644 --- a/core/audio/playback.go +++ b/core/audio/playback.go @@ -4,11 +4,12 @@ package audio // the media. type Playback interface { - // Start begins playback of the media. If the media is already playing, this method has no effect. + // Start begins playback of the media. If the media is already playing, it is repositioned to the specified time + // offset and continues playing from there. // // The at parameter specifies the starting point in seconds from the beginning of the media. If at is negative or // greater than the length of the media, it will be clamped to the valid range. - Start(at float32) + Start(at float64) // Stop halts playback of the media. If the media is not playing, this method has no effect. Stop() @@ -28,18 +29,18 @@ type Playback interface { // LoopStart returns the starting point in seconds from the beginning of the media where looping should occur. // // Default value is 0.0 seconds. - LoopStart() float32 + LoopStart() float64 // SetLoopStart sets the starting point in seconds from the beginning of the media where looping should occur. - SetLoopStart(loopStart float32) + SetLoopStart(loopStart float64) // LoopEnd returns the ending point in seconds from the beginning of the media where looping should occur. // // Default value is the length of the media. - LoopEnd() float32 + LoopEnd() float64 // SetLoopEnd sets the ending point in seconds from the beginning of the media where looping should occur. - SetLoopEnd(loopEnd float32) + SetLoopEnd(loopEnd float64) // Playing returns true if the media is currently playing, false otherwise. Playing() bool @@ -89,9 +90,6 @@ type SpatialPlayback interface { // PlaybackSettings represents the settings for creating a playback instance. type PlaybackSettings struct { - // FireAndForget indicates whether the playback should automatically release its resources after it finishes playing. - FireAndForget bool - // UseLowPassFilter indicates whether a low-pass filter should be applied to the playback. UseLowPassFilter bool From 41a7e64469b5f782eca5a6b82f2e0fee38b5f033 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 01:24:53 +0300 Subject: [PATCH 52/59] audio: util changes --- core/audio/util.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/audio/util.go b/core/audio/util.go index cec7a52e..979b0779 100644 --- a/core/audio/util.go +++ b/core/audio/util.go @@ -14,12 +14,12 @@ func GainToDB(gain float32) float32 { // SampleCount calculates the number of samples for a given duration in seconds // given the used sample rate. -func SampleCount(seconds float32, sampleRate int) int { - return int(float64(sampleRate) * float64(seconds)) +func SampleCount(seconds float64, sampleRate int) int { + return int(float64(sampleRate) * seconds) } // Seconds calculates the duration in seconds for a given number of samples // and sample rate. -func Seconds(sampleCount, sampleRate int) float32 { - return float32(sampleCount) / float32(sampleRate) +func Seconds(sampleCount, sampleRate int) float64 { + return float64(sampleCount) / float64(sampleRate) } From 54390ede3e68c94fde710b1057eecba3f601fb7b Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 15:10:47 +0300 Subject: [PATCH 53/59] audio: add nop implementation --- core/audio/nop.go | 403 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 core/audio/nop.go diff --git a/core/audio/nop.go b/core/audio/nop.go new file mode 100644 index 00000000..9b16dffc --- /dev/null +++ b/core/audio/nop.go @@ -0,0 +1,403 @@ +package audio + +import "github.com/mokiat/gomath/sprec" + +type nopAPI struct { + masterBus *nopMasterBus + listener *nopSpatialListener +} + +var _ API = (*nopAPI)(nil) + +// NewNopAPI returns a no-op implementation of the API interface. +func NewNopAPI() API { + return &nopAPI{ + masterBus: &nopMasterBus{ + compression: &nopCompression{}, + }, + listener: &nopSpatialListener{}, + } +} + +func (a *nopAPI) CreateMedia(data MediaData) Media { + return &nopMedia{} +} + +func (a *nopAPI) CreateBus(settings BusSettings) Bus { + b := &nopBus{} + if settings.UseCompression { + b.compression = &nopCompression{} + } + if settings.UseReverb { + b.reverb = &nopReverb{} + } + return b +} + +func (a *nopAPI) CreatePlayback(bus Bus, media Media, settings PlaybackSettings) Playback { + return newNopPlayback(settings) +} + +func (a *nopAPI) CreateSpatialPlayback(bus Bus, media Media, settings PlaybackSettings) SpatialPlayback { + return newNopSpatialPlayback(settings) +} + +func (a *nopAPI) MasterBus() MasterBus { + return a.masterBus +} + +func (a *nopAPI) SpatialListener() SpatialListener { + return a.listener +} + +var _ Media = (*nopMedia)(nil) + +type nopMedia struct{} + +func (m *nopMedia) Length() float64 { + return 0.0 +} + +func (m *nopMedia) Release() {} + +var _ MasterBus = (*nopMasterBus)(nil) + +type nopMasterBus struct { + gain float32 + compression *nopCompression +} + +func (b *nopMasterBus) Gain() float32 { + return b.gain +} + +func (b *nopMasterBus) SetGain(gain float32) { + b.gain = gain +} + +func (b *nopMasterBus) Compression() Compression { + return b.compression +} + +var _ Bus = (*nopBus)(nil) + +type nopBus struct { + gain float32 + compression *nopCompression + reverb *nopReverb +} + +func (b *nopBus) Gain() float32 { + return b.gain +} + +func (b *nopBus) SetGain(gain float32) { + b.gain = gain +} + +func (b *nopBus) Compression() Compression { + if b.compression == nil { + return nil + } + return b.compression +} + +func (b *nopBus) Reverb() Reverb { + if b.reverb == nil { + return nil + } + return b.reverb +} + +func (b *nopBus) Pause() {} + +func (b *nopBus) Resume() {} + +func (b *nopBus) Release() {} + +var _ Reverb = (*nopReverb)(nil) + +type nopReverb struct { + roomSize float32 + damping float32 + dry float32 + wet float32 +} + +func (r *nopReverb) RoomSize() float32 { + return r.roomSize +} + +func (r *nopReverb) SetRoomSize(size float32) { + r.roomSize = size +} + +func (r *nopReverb) Damping() float32 { + return r.damping +} + +func (r *nopReverb) SetDamping(damping float32) { + r.damping = damping +} + +func (r *nopReverb) Dry() float32 { + return r.dry +} + +func (r *nopReverb) SetDry(dry float32) { + r.dry = dry +} + +func (r *nopReverb) Wet() float32 { + return r.wet +} + +func (r *nopReverb) SetWet(wet float32) { + r.wet = wet +} + +var _ Compression = (*nopCompression)(nil) + +type nopCompression struct { + attack float32 + release float32 + ratio float32 + knee float32 + threshold float32 +} + +func (c *nopCompression) Attack() float32 { + return c.attack +} + +func (c *nopCompression) SetAttack(attack float32) { + c.attack = attack +} + +func (c *nopCompression) Release() float32 { + return c.release +} + +func (c *nopCompression) SetRelease(release float32) { + c.release = release +} + +func (c *nopCompression) Ratio() float32 { + return c.ratio +} + +func (c *nopCompression) SetRatio(ratio float32) { + c.ratio = ratio +} + +func (c *nopCompression) Knee() float32 { + return c.knee +} + +func (c *nopCompression) SetKnee(knee float32) { + c.knee = knee +} + +func (c *nopCompression) Threshold() float32 { + return c.threshold +} + +func (c *nopCompression) SetThreshold(threshold float32) { + c.threshold = threshold +} + +var _ FrequencyFilter = (*nopFrequencyFilter)(nil) + +type nopFrequencyFilter struct { + frequency float32 +} + +func (f *nopFrequencyFilter) Frequency() float32 { + return f.frequency +} + +func (f *nopFrequencyFilter) SetFrequency(frequency float32) { + f.frequency = frequency +} + +var _ Playback = (*nopPlayback)(nil) + +type nopPlayback struct { + playing bool + looping bool + loopStart float64 + loopEnd float64 + playbackRate float32 + gain float32 + lowPassFilter *nopFrequencyFilter + highPassFilter *nopFrequencyFilter + onFinished func() +} + +func newNopPlayback(settings PlaybackSettings) *nopPlayback { + p := &nopPlayback{} + if settings.UseLowPassFilter { + p.lowPassFilter = &nopFrequencyFilter{} + } + if settings.UseHighPassFilter { + p.highPassFilter = &nopFrequencyFilter{} + } + return p +} + +func (p *nopPlayback) Start(at float64) { + p.playing = true +} + +func (p *nopPlayback) Stop() { + p.playing = false +} + +func (p *nopPlayback) Pause() { + p.playing = false +} + +func (p *nopPlayback) Resume() {} + +func (p *nopPlayback) Looping() bool { + return p.looping +} + +func (p *nopPlayback) SetLooping(loop bool) { + p.looping = loop +} + +func (p *nopPlayback) LoopStart() float64 { + return p.loopStart +} + +func (p *nopPlayback) SetLoopStart(s float64) { + p.loopStart = s +} + +func (p *nopPlayback) LoopEnd() float64 { + return p.loopEnd +} + +func (p *nopPlayback) SetLoopEnd(e float64) { + p.loopEnd = e +} + +func (p *nopPlayback) Playing() bool { + return p.playing +} + +func (p *nopPlayback) PlaybackRate() float32 { + return p.playbackRate +} + +func (p *nopPlayback) SetPlaybackRate(r float32) { + p.playbackRate = r +} + +func (p *nopPlayback) Gain() float32 { + return p.gain +} + +func (p *nopPlayback) SetGain(gain float32) { + p.gain = gain +} + +func (p *nopPlayback) LowPassFilter() FrequencyFilter { + if p.lowPassFilter == nil { + return nil + } + return p.lowPassFilter +} +func (p *nopPlayback) HighPassFilter() FrequencyFilter { + if p.highPassFilter == nil { + return nil + } + return p.highPassFilter +} + +func (p *nopPlayback) SetOnFinished(fn func()) { + p.onFinished = fn +} + +func (p *nopPlayback) Release() {} + +var _ SpatialPlayback = (*nopSpatialPlayback)(nil) + +type nopSpatialPlayback struct { + *nopPlayback + position sprec.Vec3 + rotation sprec.Quat + innerConeAngle sprec.Angle + outerConeAngle sprec.Angle + outerConeGain float32 +} + +func newNopSpatialPlayback(settings PlaybackSettings) *nopSpatialPlayback { + return &nopSpatialPlayback{ + nopPlayback: newNopPlayback(settings), + } +} + +func (p *nopSpatialPlayback) Position() sprec.Vec3 { + return p.position +} + +func (p *nopSpatialPlayback) SetPosition(pos sprec.Vec3) { + p.position = pos +} + +func (p *nopSpatialPlayback) Rotation() sprec.Quat { + return p.rotation +} + +func (p *nopSpatialPlayback) SetRotation(rot sprec.Quat) { + p.rotation = rot +} + +func (p *nopSpatialPlayback) InnerConeAngle() sprec.Angle { + return p.innerConeAngle +} + +func (p *nopSpatialPlayback) SetInnerConeAngle(a sprec.Angle) { + p.innerConeAngle = a +} + +func (p *nopSpatialPlayback) OuterConeAngle() sprec.Angle { + return p.outerConeAngle +} + +func (p *nopSpatialPlayback) SetOuterConeAngle(a sprec.Angle) { + p.outerConeAngle = a +} + +func (p *nopSpatialPlayback) OuterConeGain() float32 { + return p.outerConeGain +} + +func (p *nopSpatialPlayback) SetOuterConeGain(gain float32) { + p.outerConeGain = gain +} + +var _ SpatialListener = (*nopSpatialListener)(nil) + +type nopSpatialListener struct { + position sprec.Vec3 + rotation sprec.Quat +} + +func (l *nopSpatialListener) Position() sprec.Vec3 { + return l.position +} + +func (l *nopSpatialListener) SetPosition(pos sprec.Vec3) { + l.position = pos +} + +func (l *nopSpatialListener) Rotation() sprec.Quat { + return l.rotation +} + +func (l *nopSpatialListener) SetRotation(rot sprec.Quat) { + l.rotation = rot +} From 99033c1905f2c590bb68fa1cc4d41659901609ef Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 15:23:27 +0300 Subject: [PATCH 54/59] audio: add decoder registration --- app/window.go | 2 +- core/audio/bus.go | 2 ++ core/audio/decode.go | 73 +++++++++++++++++++++++++++++++++++++++ core/audio/doc.go | 8 +++++ core/audio/mp3/decoder.go | 4 +++ core/audio/mp3/doc.go | 5 +++ core/audio/playback.go | 7 ++++ core/audio/spatial.go | 3 ++ core/audio/wav/decoder.go | 4 +++ core/audio/wav/doc.go | 5 +++ 10 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 core/audio/decode.go create mode 100644 core/audio/doc.go create mode 100644 core/audio/mp3/doc.go create mode 100644 core/audio/wav/doc.go diff --git a/app/window.go b/app/window.go index 04e37e56..083ccde8 100644 --- a/app/window.go +++ b/app/window.go @@ -1,7 +1,7 @@ package app import ( - "github.com/mokiat/lacking/audio" + "github.com/mokiat/lacking/core/audio" "github.com/mokiat/lacking/render" ) diff --git a/core/audio/bus.go b/core/audio/bus.go index d8e86ffc..f39ce631 100644 --- a/core/audio/bus.go +++ b/core/audio/bus.go @@ -9,6 +9,8 @@ type Bus interface { Gain() float32 // SetGain sets the gain of the bus. + // + // The value must be non-negative. SetGain(gain float32) // Compression returns the compression controls of the bus. diff --git a/core/audio/decode.go b/core/audio/decode.go new file mode 100644 index 00000000..755d1393 --- /dev/null +++ b/core/audio/decode.go @@ -0,0 +1,73 @@ +package audio + +import ( + "bufio" + "bytes" + "errors" + "io" + "sync" +) + +// DecodeFunc is a function that decodes audio data from an io.Reader and +// returns a slice of audio frames. +type DecodeFunc func(io.Reader) (MediaData, error) + +// RegisterDecoder registers an audio decoder for use by [Decode]. +// +// The name parameter is a human-readable identifier for the format (e.g. +// "mp3", "wav"). The magic parameter is a magic byte prefix that +// identifies the format in raw data. The decode parameter is the function that +// will be called to decode data matching any of the magic prefixes. +func RegisterDecoder(name, magic string, decode DecodeFunc) { + registryMu.Lock() + defer registryMu.Unlock() + + registeredFormats = append(registeredFormats, decoderFormatEntry{ + name: name, + magic: []byte(magic), + decode: decode, + }) +} + +// Decode decodes audio data encoded in a registered format. +// +// It returns the decoded [MediaData], the name of the detected format (as +// registered via [RegisterDecoder]), and any error encountered. If the format +// cannot be determined, [errors.ErrUnsupported] is returned. +func Decode(r io.Reader) (MediaData, string, error) { + in := bufio.NewReader(r) + + decodeFn, name, err := findDecoder(in) + if err != nil { + return MediaData{}, "", err + } + + data, err := decodeFn(in) + return data, name, err +} + +func findDecoder(r *bufio.Reader) (DecodeFunc, string, error) { + registryMu.Lock() + defer registryMu.Unlock() + + for _, f := range registeredFormats { + count := len(f.magic) + actualMagic, err := r.Peek(count) + if err == nil && bytes.Equal(f.magic, actualMagic) { + return f.decode, f.name, nil + } + } + + return nil, "", errors.ErrUnsupported +} + +var ( + registryMu sync.Mutex + registeredFormats []decoderFormatEntry +) + +type decoderFormatEntry struct { + name string + magic []byte + decode DecodeFunc +} diff --git a/core/audio/doc.go b/core/audio/doc.go new file mode 100644 index 00000000..dee74fc3 --- /dev/null +++ b/core/audio/doc.go @@ -0,0 +1,8 @@ +// Package audio provides a platform-agnostic audio API covering media loading, +// playback control, spatial (3D) audio, and a format decoder registry. +// Platform-specific implementations satisfy the [API] interface; a no-op +// implementation ([NewNopAPI]) is available for headless or test operation. +// Format decoder plugins (e.g. the mp3 and wav sub-packages) self-register +// via their package init functions and are selected at decode time by +// magic-byte prefix matching. +package audio diff --git a/core/audio/mp3/decoder.go b/core/audio/mp3/decoder.go index 914b2fa2..b6ea1d7d 100644 --- a/core/audio/mp3/decoder.go +++ b/core/audio/mp3/decoder.go @@ -9,6 +9,10 @@ import ( "github.com/mokiat/lacking/core/audio" ) +func init() { + audio.RegisterDecoder("mp3", "ID3", Decode) +} + // Decode decodes MP3 data from the provided reader and returns the decoded // audio frames. func Decode(in io.Reader) (audio.MediaData, error) { diff --git a/core/audio/mp3/doc.go b/core/audio/mp3/doc.go new file mode 100644 index 00000000..71615cb9 --- /dev/null +++ b/core/audio/mp3/doc.go @@ -0,0 +1,5 @@ +// Package mp3 provides an MP3 audio decoder for the audio package. +// Importing this package is sufficient to register the decoder — the init +// function calls [audio.RegisterDecoder] so that [audio.Decode] can handle +// MP3 data identified by the "ID3" magic prefix. +package mp3 diff --git a/core/audio/playback.go b/core/audio/playback.go index e8481408..029db211 100644 --- a/core/audio/playback.go +++ b/core/audio/playback.go @@ -46,9 +46,14 @@ type Playback interface { Playing() bool // PlaybackRate returns the current playback rate of the media. + // + // A value of 1.0 represents normal speed. Default value is 1.0. PlaybackRate() float32 // SetPlaybackRate sets the playback rate of the media. + // + // A value of 1.0 represents normal speed; values above 1.0 play faster + // and values below 1.0 play slower. The value must be positive. SetPlaybackRate(rate float32) // Gain returns the current gain of the playback. @@ -57,6 +62,8 @@ type Playback interface { Gain() float32 // SetGain sets the gain of the playback. + // + // The value must be non-negative. SetGain(gain float32) // LowPassFilter returns the low-pass filter applied to this playback, if any. If no low-pass filter is applied, diff --git a/core/audio/spatial.go b/core/audio/spatial.go index 36b122f0..66e26c8b 100644 --- a/core/audio/spatial.go +++ b/core/audio/spatial.go @@ -34,6 +34,9 @@ type SpatialEmitter interface { SetRotation(rotation sprec.Quat) // InnerConeAngle returns the inner cone angle of the emitter. + // + // Within this cone the emitter plays at full gain. Between the inner and + // outer cone the gain is linearly interpolated toward [OuterConeGain]. InnerConeAngle() sprec.Angle // SetInnerConeAngle sets the inner cone angle of the emitter. diff --git a/core/audio/wav/decoder.go b/core/audio/wav/decoder.go index d70ba487..aaf9d822 100644 --- a/core/audio/wav/decoder.go +++ b/core/audio/wav/decoder.go @@ -8,6 +8,10 @@ import ( "github.com/mokiat/lacking/core/audio" ) +func init() { + audio.RegisterDecoder("wav", "RIFF", Decode) +} + // Decode decodes WAV data from the provided reader and returns the decoded // audio frames. func Decode(in io.Reader) (audio.MediaData, error) { diff --git a/core/audio/wav/doc.go b/core/audio/wav/doc.go new file mode 100644 index 00000000..f9142012 --- /dev/null +++ b/core/audio/wav/doc.go @@ -0,0 +1,5 @@ +// Package wav provides a WAV audio decoder for the audio package. +// Importing this package is sufficient to register the decoder — the init +// function calls [audio.RegisterDecoder] so that [audio.Decode] can handle +// WAV data identified by the "RIFF" magic prefix. +package wav From f45ac52536435aa7ce44e049a4956f259a102184 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 19:16:01 +0300 Subject: [PATCH 55/59] project: switch to core audio api --- ui/component/scope_context.go | 7 +++++ ui/resource_manager.go | 8 ++--- ui/sound.go | 58 ++++++++++++++++++++++------------- ui/window.go | 10 ++++++ 4 files changed, 58 insertions(+), 25 deletions(-) diff --git a/ui/component/scope_context.go b/ui/component/scope_context.go index 80c4eb0a..8f695915 100644 --- a/ui/component/scope_context.go +++ b/ui/component/scope_context.go @@ -126,6 +126,13 @@ func OpenSound(scope Scope, uri string) *ui.Sound { return sound } +// PlaySound uses the ui.Context from the specified scope to play the provided +// sound. +func PlaySound(scope Scope, sound *ui.Sound) { + window := scope.Context().Window() + window.Mixer().PlaySound(sound) +} + // ContextScope returns a new Scope that extends the specified parent scope // but uses a different ui.Context. This can be used to have sections of the // UI use a dedicated resource set. diff --git a/ui/resource_manager.go b/ui/resource_manager.go index 82d4ec09..35a79dca 100644 --- a/ui/resource_manager.go +++ b/ui/resource_manager.go @@ -7,9 +7,9 @@ import ( _ "image/png" "io" - "github.com/mokiat/lacking/audio" - _ "github.com/mokiat/lacking/audio/mp3" - _ "github.com/mokiat/lacking/audio/wav" + "github.com/mokiat/lacking/core/audio" + _ "github.com/mokiat/lacking/core/audio/mp3" + _ "github.com/mokiat/lacking/core/audio/wav" "github.com/mokiat/lacking/resource" "golang.org/x/image/font/opentype" ) @@ -108,7 +108,7 @@ func (m *resourceManager) OpenFontCollection(uri string) (*FontCollection, error func (m *resourceManager) CreateSound(data audio.MediaData) *Sound { media := m.audioAPI.CreateMedia(data) - return newSound(m.audioAPI, media) + return newSound(media) } func (m *resourceManager) OpenSound(uri string) (*Sound, error) { diff --git a/ui/sound.go b/ui/sound.go index f1081041..fd395e5b 100644 --- a/ui/sound.go +++ b/ui/sound.go @@ -1,39 +1,55 @@ package ui import ( - "github.com/mokiat/gog/opt" - "github.com/mokiat/lacking/audio" + "github.com/mokiat/gomath/sprec" + "github.com/mokiat/lacking/core/audio" ) -var globalAudioGain = float32(1.0) +type Mixer struct { + api audio.API + bus audio.Bus +} + +func newMixer(api audio.API) *Mixer { + return &Mixer{ + api: api, + bus: api.CreateBus(audio.BusSettings{}), + } +} + +func (m *Mixer) Gain() float32 { + return m.bus.Gain() +} + +func (m *Mixer) SetGain(gain float32) { + m.bus.SetGain(gain) +} -func GlobalAudioGain() float32 { - return globalAudioGain +func (m *Mixer) Volume() float32 { + gain := max(0.0, m.Gain()) + return sprec.Pow(gain, 1.0/2.0) } -func SetGlobalAudioGain(gain float32) { - globalAudioGain = gain +func (m *Mixer) SetVolume(volume float32) { + volume = max(0.0, volume) + gain := sprec.Pow(volume, 2.0) + m.SetGain(gain) } -func newSound(api audio.API, media audio.Media) *Sound { +func (m *Mixer) PlaySound(sound *Sound) { + playback := m.api.CreatePlayback(m.bus, sound.media, audio.PlaybackSettings{}) + playback.SetOnFinished(func() { + playback.Release() + }) + playback.Start(0.0) +} + +func newSound(media audio.Media) *Sound { return &Sound{ - api: api, media: media, } } type Sound struct { - api audio.API media audio.Media } - -func (s *Sound) Play(gain float32) { - if s == nil { - return - } - s.api.Play(s.media, audio.PlayInfo{ - Loop: false, - Gain: opt.V(gain * globalAudioGain), - Pan: 0.0, - }) -} diff --git a/ui/window.go b/ui/window.go index 3bf970f5..ba52b0c0 100644 --- a/ui/window.go +++ b/ui/window.go @@ -11,9 +11,12 @@ import ( // newWindow creates a new Window instance that integrates // with the specified app.Window. func newWindow(appWindow app.Window, canvas *Canvas, resMan *resourceManager) (*Window, WindowHandler) { + audioAPI := appWindow.AudioAPI() + window := &Window{ Window: appWindow, canvas: canvas, + mixer: newMixer(audioAPI), oldEnteredElements: make(map[*Element]struct{}), enteredElements: make(map[*Element]struct{}), lastRender: time.Now(), @@ -73,6 +76,7 @@ type Window struct { app.Window context *Context canvas *Canvas + mixer *Mixer size Size root *Element @@ -107,6 +111,12 @@ func (w *Window) Context() *Context { return w.context } +// Mixer returns the audio Mixer associated with this Window, which can be used +// to play sounds and manage audio volume. +func (w *Window) Mixer() *Mixer { + return w.mixer +} + // Root returns the Element that represents this Window. func (w *Window) Root() *Element { return w.root From 416ae5da664c9487fbe3d90e33808e7480f13a2e Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 19:16:12 +0300 Subject: [PATCH 56/59] audio: remove legacy api --- audio/api.go | 83 ---------- audio/decode.go | 44 ----- audio/doc.go | 15 -- audio/format.go | 35 ---- audio/frame.go | 56 ------- audio/listener.go | 26 --- audio/media.go | 23 --- audio/mp3/decoder.go | 61 ------- audio/node.go | 344 -------------------------------------- audio/nop.go | 386 ------------------------------------------- audio/playback.go | 26 --- audio/util.go | 25 --- audio/wav/decoder.go | 60 ------- 13 files changed, 1184 deletions(-) delete mode 100644 audio/api.go delete mode 100644 audio/decode.go delete mode 100644 audio/doc.go delete mode 100644 audio/format.go delete mode 100644 audio/frame.go delete mode 100644 audio/listener.go delete mode 100644 audio/media.go delete mode 100644 audio/mp3/decoder.go delete mode 100644 audio/node.go delete mode 100644 audio/nop.go delete mode 100644 audio/playback.go delete mode 100644 audio/util.go delete mode 100644 audio/wav/decoder.go diff --git a/audio/api.go b/audio/api.go deleted file mode 100644 index d7d6b1ba..00000000 --- a/audio/api.go +++ /dev/null @@ -1,83 +0,0 @@ -package audio - -// API provides access to a low-level audio manipulation and playback. -// -// All methods must be called from the UI thread. -type API interface { - - // SampleRate returns the audio sample rate used by the API (i.e. how - // many samples there are in a single second). - SampleRate() int - - // CreateMedia creates a new Media object from the specified frames. - // - // If the provided frames are not in the sample rate of the API, they will be - // resampled to match the API's sample rate. - // - // Keep in mind that the implementation may keep a reference to the provided - // data, so it should not be modified after being passed to this method. - CreateMedia(data MediaData) Media - - // Output returns the output audio node. - Output() Node - - // SpatialListener returns the spatial listener used for 3D audio. - SpatialListener() SpatialListener - - // CreatePlaybackNode creates a new playback node for the specified media. - // - // It is safe to delete the media after creating the playback node. - CreatePlaybackNode(media Media) PlaybackNode - - // CreateOscillatorNode creates a new oscillator node. - CreateOscillatorNode() OscillatorNode - - // CreateGainNode creates a new gain node. - CreateGainNode() GainNode - - // CreatePanNode creates a new pan node. - CreatePanNode() PanNode - - // CreateSpatialNode creates a new spatial audio node. - CreateSpatialNode() SpatialNode - - // CreateHighPassNode creates a new high-pass filter node. - CreateHighPassNode() HighPassNode - - // CreateLowPassNode creates a new low-pass filter node. - CreateLowPassNode() LowPassNode - - // CreateDelayNode creates a new delay node. - CreateDelayNode() DelayNode - - // CreateReverbNode creates a new reverb node. - CreateReverbNode() ReverbNode - - // CreateCompressorNode creates a new compressor node. - CreateCompressorNode() CompressorNode - - // CreateConnectorNode creates a new connector node. It is a pass-through - // node that forwards its input signal unchanged, useful as a named - // connection point in a larger node graph. - CreateConnectorNode() ConnectorNode - - // Chain connects the specified nodes in sequence. This is a convenience - // function that uses [API.Connect]. Beware that it may incur allocations - // due to variadic parameters. - Chain(nodes ...Node) - - // Connect connects the source node to the target node. - // - // The audio signal from the source node will be added to the audio - // signal from any other nodes that are already connected to the target node. - Connect(source, target Node) - - // Disconnect disconnects the source node from the target node. - Disconnect(source, target Node) - - // Play plays the specified media as soon as possible. - // - // Deprecated: Use [API.CreatePlaybackNode] and [PlaybackNode.Play] instead - // for more control over playback. - Play(media Media, info PlayInfo) Playback -} diff --git a/audio/decode.go b/audio/decode.go deleted file mode 100644 index 0c714522..00000000 --- a/audio/decode.go +++ /dev/null @@ -1,44 +0,0 @@ -package audio - -import ( - "bufio" - "bytes" - "errors" - "io" -) - -// DecodeFunc is a function that decodes audio data from an io.Reader and -// returns a slice of audio frames. -type DecodeFunc func(io.Reader) (MediaData, error) - -// Decode decodes audio data encoded in a registered format. -// -// It returns the decoded [MediaData], the name of the detected format (as -// registered via [RegisterFormat]), and any error encountered. If the format -// cannot be determined, [errors.ErrUnsupported] is returned. -func Decode(r io.Reader) (MediaData, string, error) { - in := bufio.NewReader(r) - - decodeFn, name, err := findDecoder(in) - if err != nil { - return MediaData{}, "", err - } - - data, err := decodeFn(in) - return data, name, err -} - -func findDecoder(r *bufio.Reader) (DecodeFunc, string, error) { - registryMu.Lock() - defer registryMu.Unlock() - - for _, f := range registeredFormats { - count := len(f.magic) - actualMagic, err := r.Peek(count) - if err == nil && bytes.Equal(f.magic, actualMagic) { - return f.decode, f.name, nil - } - } - - return nil, "", errors.ErrUnsupported -} diff --git a/audio/doc.go b/audio/doc.go deleted file mode 100644 index d554ed71..00000000 --- a/audio/doc.go +++ /dev/null @@ -1,15 +0,0 @@ -// Package audio defines the audio API used by the engine. -// -// The API is built around a node graph model. Audio sources (e.g. -// [PlaybackNode], [OscillatorNode]) produce signals that flow through -// processing nodes (e.g. [GainNode], [ReverbNode]) and ultimately reach the -// output node returned by [API.Output]. Nodes are connected with [API.Connect] -// and disconnected with [API.Disconnect]. When multiple sources are connected -// to the same target their signals are mixed additively. -// -// All created nodes implement [UserNode] and must be explicitly deleted via -// [UserNode.Delete] when no longer needed, otherwise resources will leak. -// -// The nop implementation ([NewNopAPI]) provides a fully functional but silent -// API suitable for headless operation and testing. -package audio diff --git a/audio/format.go b/audio/format.go deleted file mode 100644 index 6fc08734..00000000 --- a/audio/format.go +++ /dev/null @@ -1,35 +0,0 @@ -package audio - -import ( - "sync" -) - -// RegisterFormat registers an audio format for use by [Decode]. -// -// The name parameter is a human-readable identifier for the format (e.g. -// "mp3", "wav"). The magics parameter is a list of magic byte prefixes that -// identify the format in raw data. The decode parameter is the function that -// will be called to decode data matching any of the magic prefixes. -func RegisterFormat(name string, magics []string, decode DecodeFunc) { - registryMu.Lock() - defer registryMu.Unlock() - - for _, magic := range magics { - registeredFormats = append(registeredFormats, formatEntry{ - name: name, - magic: []byte(magic), - decode: decode, - }) - } -} - -var ( - registryMu sync.Mutex - registeredFormats []formatEntry -) - -type formatEntry struct { - name string - magic []byte - decode DecodeFunc -} diff --git a/audio/frame.go b/audio/frame.go deleted file mode 100644 index a4881655..00000000 --- a/audio/frame.go +++ /dev/null @@ -1,56 +0,0 @@ -package audio - -import ( - "math" - - "github.com/mokiat/gomath/sprec" -) - -// Frame represents a PCM frame with left and right channel data. -type Frame struct { - - // Left is the left channel sample value. - Left float32 - - // Right is the right channel sample value. - Right float32 -} - -// Resample resamples the given audio frames from one sample rate to another. -func Resample(frames []Frame, fromRate int, toRate int) []Frame { - if (fromRate == toRate) || (len(frames) == 0) { - return frames - } - if fromRate <= 0 || toRate <= 0 { - panic("invalid sample rate") - } - oldLength := len(frames) - - scale := float64(toRate) / float64(fromRate) - newLength := int(float64(len(frames))*scale + 0.5) - if newLength <= 0 { - return nil - } - if newLength == 1 { - return []Frame{frames[0]} - } - - result := make([]Frame, newLength) - step := float64(oldLength-1) / float64(newLength-1) - for i := range newLength { - srcPosition, srcFraction := math.Modf(float64(i) * step) - srcIndexPrev := min(int(srcPosition), oldLength-1) - srcIndexNext := min(srcIndexPrev+1, oldLength-1) - if srcIndexPrev == srcIndexNext { - result[i] = frames[srcIndexPrev] - } else { - prev := frames[srcIndexPrev] - next := frames[srcIndexNext] - result[i] = Frame{ - Left: sprec.Mix(prev.Left, next.Left, float32(srcFraction)), - Right: sprec.Mix(prev.Right, next.Right, float32(srcFraction)), - } - } - } - return result -} diff --git a/audio/listener.go b/audio/listener.go deleted file mode 100644 index 0303beda..00000000 --- a/audio/listener.go +++ /dev/null @@ -1,26 +0,0 @@ -package audio - -import "github.com/mokiat/gomath/sprec" - -// SpatialListener represents a listener in 3D space for spatial audio. -// -// The listener's position and orientation can be used to create spatial audio -// effects, such as panning and distance attenuation, for [SpatialNode] sources. -// -// Distance attenuation is applied to [SpatialNode] sources based on the -// distance between the source and the listener. The attenuation model is -// "inverse", where the gain is calculated as 1.0 / max(1.0, distance). -type SpatialListener interface { - - // Position returns the 3D position of the listener. - Position() sprec.Vec3 - - // SetPosition sets the 3D position of the listener. - SetPosition(position sprec.Vec3) - - // Rotation returns the orientation of the listener as a quaternion. - Rotation() sprec.Quat - - // SetRotation sets the orientation of the listener as a quaternion. - SetRotation(rotation sprec.Quat) -} diff --git a/audio/media.go b/audio/media.go deleted file mode 100644 index 91d8621a..00000000 --- a/audio/media.go +++ /dev/null @@ -1,23 +0,0 @@ -package audio - -import "time" - -// MediaData represents the raw audio data and its associated metadata. -type MediaData struct { - - // Frames contains the decoded audio frames. - Frames []Frame - - // SampleRate is the sample rate of the audio data. - SampleRate int -} - -// Media represents a playable audio sequence. -type Media interface { - - // Length returns the duration of the media. - Length() time.Duration - - // Delete frees any resources used by this media. - Delete() -} diff --git a/audio/mp3/decoder.go b/audio/mp3/decoder.go deleted file mode 100644 index 8852c012..00000000 --- a/audio/mp3/decoder.go +++ /dev/null @@ -1,61 +0,0 @@ -package mp3 - -import ( - "io" - "math" - - "github.com/hajimehoshi/go-mp3" - "github.com/mokiat/gblob" - "github.com/mokiat/lacking/audio" -) - -func init() { - magics := []string{ - "ID3", - } - audio.RegisterFormat("mp3", magics, Decode) -} - -// Decode decodes MP3 data from the provided reader and returns the decoded -// audio frames. -func Decode(in io.Reader) (audio.MediaData, error) { - decoder, err := mp3.NewDecoder(in) - if err != nil { - return audio.MediaData{}, err - } - - // TODO: There must be a faster and cheaper way to do this. - // 1. The decoder has overhead from being able to Seek. If an implementation - // is used/written that doesn't support seeking, some overhead can be avoided. - // 2. It might be possible to decode directly via a LittleEndian decoder - // without having to read the entire data into memory. - data, err := io.ReadAll(decoder) - if err != nil { - return audio.MediaData{}, err - } - buffer := gblob.LittleEndianBlock(data) - - length := len(data) / 4 - frames := make([]audio.Frame, length) - for i := range length { - leftInt16 := buffer.Int16(i*4 + 0) - rightInt16 := buffer.Int16(i*4 + 2) - frames[i] = audio.Frame{ - Left: int16ToFloat32(leftInt16), - Right: int16ToFloat32(rightInt16), - } - } - - return audio.MediaData{ - Frames: frames, - SampleRate: decoder.SampleRate(), - }, nil -} - -func int16ToFloat32(value int16) float32 { - if value >= 0 { - return float32(value) / float32(math.MaxInt16) - } else { - return -float32(value) / float32(math.MinInt16) - } -} diff --git a/audio/node.go b/audio/node.go deleted file mode 100644 index 2b3555cd..00000000 --- a/audio/node.go +++ /dev/null @@ -1,344 +0,0 @@ -package audio - -import "github.com/mokiat/gomath/sprec" - -const ( - // DefaultFrequency is the default frequency for OscillatorNode. - DefaultFrequency = 440.0 - - // DefaultGain is the default gain factor for GainNode, representing no - // change to the audio signal. - DefaultGain = 1.0 - - // DefaultPan is the default pan value for PanNode, representing a - // centered audio signal. - DefaultPan = 0.0 - - // DefaultCutoffFrequency is the default cutoff frequency for HighPassNode - // and LowPassNode. - DefaultCutoffFrequency = 350.0 - - // DefaultDelay is the default delay time for DelayNode, representing no - // delay. - DefaultDelay = 0.0 - - // DefaultRoomSize is the default room size for ReverbNode, representing a - // small room. - DefaultRoomSize = 0.3 - - // DefaultDamping is the default damping factor for ReverbNode, representing - // a moderate amount of damping. - DefaultDamping = 0.5 - - // DefaultDry is the default dry level for ReverbNode, representing the - // original signal at full gain. - DefaultDry = 1.0 - - // DefaultWet is the default wet level for ReverbNode, representing the - // reverberated signal at half gain. - DefaultWet = 0.5 - - // DefaultAttack is the default attack time for CompressorNode. - DefaultAttack = 0.003 - - // DefaultRelease is the default release time for CompressorNode. - DefaultRelease = 0.25 - - // DefaultRatio is the default compression ratio for CompressorNode. - DefaultRatio = 12.0 - - // DefaultKnee is the default knee width for CompressorNode. - DefaultKnee = 30.0 - - // DefaultThreshold is the default threshold level for CompressorNode. - DefaultThreshold = -24.0 -) - -// Node represents a node in a chain of audio elements. Each node produces -// audio data which can be synthesized, processed, or played back. -type Node interface { - _isAudioNode() // marker method -} - -// UserNode represents an audio node that requires explicit resource management. -type UserNode interface { - Node - - // Delete releases any resources associated with the node. After calling - // this method, the node should not be used anymore as it may be reused - // by the audio system. - Delete() -} - -// PlaybackNode represents an audio node that plays back audio data from a -// Media source. -type PlaybackNode interface { - UserNode - - // Start starts the playback of the audio. - // - // The offset parameter specifies the position in seconds from which to start - // the playback. - Start(offset float32) - - // Stop stops the playback of the audio. - Stop() - - // Resume resumes a paused playback from where it was paused. - // - // If the playback is already playing, this method has no effect. If the - // playback is stopped rather than paused, this method starts from the - // beginning (equivalent to Start(0)). - Resume() - - // Pause pauses the playback of the audio. - // - // If the playback is already paused or stopped, this method has no effect. - Pause() - - // IsPlaying returns true if the playback is currently playing. - IsPlaying() bool - - // IsLoop returns true if the playback is set to loop when it reaches the end - // of the media. - IsLoop() bool - - // SetLoop sets whether the playback should loop when it reaches the end of - // the media. - SetLoop(loop bool) - - // LoopStart returns the loop start position in seconds. - LoopStart() float32 - - // SetLoopStart sets the loop start position in seconds. - SetLoopStart(loopStart float32) - - // LoopEnd returns the loop end position in seconds. - LoopEnd() float32 - - // SetLoopEnd sets the loop end position in seconds. - SetLoopEnd(loopEnd float32) -} - -// OscillatorNode represents an audio node that generates periodic waveforms. -type OscillatorNode interface { - UserNode - - // Frequency returns the frequency of the oscillator in Hertz. - // - // Default value is 440.0 Hz (A4 note). - Frequency() float32 - - // SetFrequency sets the frequency of the oscillator in Hertz. - SetFrequency(frequency float32) -} - -// GainNode represents an audio node that applies a gain (volume adjustment) to -// the audio signal. -type GainNode interface { - UserNode - - // Gain returns the gain factor applied to the audio signal. - // - // A value of 1.0 means no change, 0.0 is silence, and values greater than - // 1.0 amplify the signal. - // - // Default value is 1.0. - Gain() float32 - - // SetGain sets the gain factor applied to the audio signal. - // - // The value must be non-negative. - SetGain(gain float32) -} - -// PanNode represents an audio node that applies panning to the audio signal, -// distributing the signal between left and right channels. -type PanNode interface { - UserNode - - // Pan returns the pan value, where -1.0 is full left, 0.0 is center, and - // 1.0 is full right. - // - // Default value is 0.0 (center). - Pan() float32 - - // SetPan sets the pan value, where -1.0 is full left, 0.0 is center, and - // 1.0 is full right. - SetPan(pan float32) -} - -// SpatialNode represents an audio node that provides spatial audio effects. -// Implementations must apply inverse distance attenuation relative to the -// [SpatialListener] returned by [API.SpatialListener]. -type SpatialNode interface { - UserNode - - // Position returns the 3D position of the audio source. - Position() sprec.Vec3 - - // SetPosition sets the 3D position of the audio source. - SetPosition(position sprec.Vec3) -} - -// HighPassNode represents an audio node that applies a high-pass filter to -// the audio signal. -type HighPassNode interface { - UserNode - - // CutoffFrequency returns the cutoff frequency of the high-pass filter in - // Hertz. - // - // Default value is 350.0 Hz. - CutoffFrequency() float32 - - // SetCutoffFrequency sets the cutoff frequency of the high-pass filter in - // Hertz. - SetCutoffFrequency(frequency float32) -} - -// LowPassNode represents an audio node that applies a low-pass filter to -// the audio signal. -type LowPassNode interface { - UserNode - - // CutoffFrequency returns the cutoff frequency of the low-pass filter in - // Hertz. - // - // Default value is 350.0 Hz. - CutoffFrequency() float32 - - // SetCutoffFrequency sets the cutoff frequency of the low-pass filter in - // Hertz. - SetCutoffFrequency(frequency float32) -} - -// DelayNode represents an audio node that applies a delay effect to the audio -// signal. -type DelayNode interface { - UserNode - - // DelayTime returns the delay time in seconds. - // - // Default value is 0.0 seconds (no delay). - DelayTime() float32 - - // SetDelayTime sets the delay time in seconds. - // - // The maximum supported delay time may be limited by the implementation - // but should be at least 1 second. - SetDelayTime(delayTime float32) -} - -// ReverbNode represents an audio node that applies a reverb effect to the -// audio signal. -type ReverbNode interface { - UserNode - - // RoomSize returns the size of the virtual room for the reverb effect. - // - // The value is in the range [0.0, 1.0]. Default value is 0.3. - RoomSize() float32 - - // SetRoomSize sets the size of the virtual room for the reverb effect. - // - // The value will be clamped to the range [0.0, 1.0]. - SetRoomSize(size float32) - - // Damping returns the damping factor of the reverb effect. - // - // Higher values cause high frequencies to decay faster, simulating - // absorptive surfaces. - // - // Default value is 0.5. - Damping() float32 - - // SetDamping sets the damping factor of the reverb effect. - // - // The value will be clamped to the range [0.0, 1.0]. - SetDamping(damping float32) - - // Dry returns the dry level of the reverb effect. - // - // Default value is 1.0. - Dry() float32 - - // SetDry sets the dry level of the reverb effect. - // - // The value will be clamped to the range [0.0, 1.0]. - SetDry(dry float32) - - // Wet returns the wet level of the reverb effect. - // - // Default value is 0.5. - Wet() float32 - - // SetWet sets the wet level of the reverb effect. - // - // The value will be clamped to the range [0.0, 1.0]. - SetWet(wet float32) -} - -// CompressorNode represents an audio node that applies dynamic range -// compression to the audio signal. -type CompressorNode interface { - UserNode - - // Attack returns the attack time in seconds. - // - // Default value is 0.003 seconds. - Attack() float32 - - // SetAttack sets the attack time in seconds. - // - // The value will be clamped to the range [0.0, 1.0]. - SetAttack(attack float32) - - // Release returns the release time in seconds. - // - // Default value is 0.25 seconds. - Release() float32 - - // SetRelease sets the release time in seconds. - // - // The value will be clamped to the range [0.0, 1.0]. - SetRelease(release float32) - - // Ratio returns the compression ratio. - // - // Default value is 12.0. - Ratio() float32 - - // SetRatio sets the compression ratio. - // - // The value will be clamped to the range [1.0, 20.0]. - SetRatio(ratio float32) - - // Knee returns the knee width in decibels. - // - // Default value is 30.0 dB. - Knee() float32 - - // SetKnee sets the knee width in decibels. - // - // The value will be clamped to the range [0.0, 40.0]. - SetKnee(knee float32) - - // Threshold returns the threshold level in decibels. - // - // Default value is -24.0 dB. - Threshold() float32 - - // SetThreshold sets the threshold level in decibels. - // - // The value will be clamped to the range [-100.0, 0.0]. - SetThreshold(threshold float32) -} - -// ConnectorNode represents a pass-through audio node that forwards its input -// signal unchanged. It is useful as a named connection point in a larger node -// graph, allowing portions of the graph to be rewired without touching every -// connected source. -type ConnectorNode interface { - UserNode -} diff --git a/audio/nop.go b/audio/nop.go deleted file mode 100644 index 4675b907..00000000 --- a/audio/nop.go +++ /dev/null @@ -1,386 +0,0 @@ -package audio - -import ( - "time" - - "github.com/mokiat/gomath/sprec" -) - -// NewNopAPI returns an API that does nothing. -func NewNopAPI() API { - return &nopAPI{ - listener: &nopListener{ - rotation: sprec.IdentityQuat(), - }, - output: &nopNode{}, - } -} - -type nopAPI struct { - listener *nopListener - output *nopNode -} - -func (a *nopAPI) SampleRate() int { - return 44100 -} - -func (a *nopAPI) CreateMedia(data MediaData) Media { - return NewNopMedia() -} - -func (a *nopAPI) Output() Node { - return a.output -} - -func (a *nopAPI) SpatialListener() SpatialListener { - return a.listener -} - -func (a *nopAPI) CreatePlaybackNode(media Media) PlaybackNode { - return &nopPlaybackNode{} -} - -func (a *nopAPI) CreateOscillatorNode() OscillatorNode { - return &nopOscillatorNode{ - frequency: DefaultFrequency, - } -} - -func (a *nopAPI) CreateGainNode() GainNode { - return &nopGainNode{ - gain: DefaultGain, - } -} - -func (a *nopAPI) CreatePanNode() PanNode { - return &nopPanNode{ - pan: DefaultPan, - } -} - -func (a *nopAPI) CreateSpatialNode() SpatialNode { - return &nopSpatialNode{} -} - -func (a *nopAPI) CreateHighPassNode() HighPassNode { - return &nopHighPassNode{ - cutoffFrequency: DefaultCutoffFrequency, - } -} - -func (a *nopAPI) CreateLowPassNode() LowPassNode { - return &nopLowPassNode{ - cutoffFrequency: DefaultCutoffFrequency, - } -} - -func (a *nopAPI) CreateDelayNode() DelayNode { - return &nopDelayNode{ - delayTime: DefaultDelay, - } -} - -func (a *nopAPI) CreateReverbNode() ReverbNode { - return &nopReverbNode{ - roomSize: DefaultRoomSize, - damping: DefaultDamping, - dry: DefaultDry, - wet: DefaultWet, - } -} - -func (a *nopAPI) CreateCompressorNode() CompressorNode { - return &nopCompressorNode{ - attack: DefaultAttack, - release: DefaultRelease, - ratio: DefaultRatio, - knee: DefaultKnee, - threshold: DefaultThreshold, - } -} - -func (a *nopAPI) CreateConnectorNode() ConnectorNode { - return &nopUserNode{} -} - -func (a *nopAPI) Chain(nodes ...Node) {} - -func (a *nopAPI) Connect(source, target Node) {} - -func (a *nopAPI) Disconnect(source, target Node) {} - -func (a *nopAPI) Play(media Media, info PlayInfo) Playback { - return &nopPlayback{} -} - -// NewNopMedia returns a Media that does nothing. -func NewNopMedia() Media { - return &nopMedia{} -} - -type nopMedia struct{} - -func (m *nopMedia) Length() time.Duration { - return 0 -} - -func (m *nopMedia) Delete() {} - -type nopListener struct { - position sprec.Vec3 - rotation sprec.Quat -} - -func (l *nopListener) Position() sprec.Vec3 { - return l.position -} - -func (l *nopListener) SetPosition(position sprec.Vec3) { - l.position = position -} - -func (l *nopListener) Rotation() sprec.Quat { - return l.rotation -} - -func (l *nopListener) SetRotation(rotation sprec.Quat) { - l.rotation = rotation -} - -type nopNode struct { - Node // marker interface -} - -type nopUserNode struct { - Node // marker interface -} - -func (n *nopUserNode) Delete() {} - -type nopPlaybackNode struct { - nopUserNode - loop bool - loopStart float32 - loopEnd float32 -} - -func (n *nopPlaybackNode) Start(offset float32) {} - -func (n *nopPlaybackNode) Stop() {} - -func (n *nopPlaybackNode) Resume() {} - -func (n *nopPlaybackNode) Pause() {} - -func (n *nopPlaybackNode) IsPlaying() bool { - return false -} - -func (n *nopPlaybackNode) IsLoop() bool { - return n.loop -} - -func (n *nopPlaybackNode) SetLoop(loop bool) { - n.loop = loop -} - -func (n *nopPlaybackNode) LoopStart() float32 { - return n.loopStart -} - -func (n *nopPlaybackNode) SetLoopStart(loopStart float32) { - n.loopStart = loopStart -} - -func (n *nopPlaybackNode) LoopEnd() float32 { - return n.loopEnd -} - -func (n *nopPlaybackNode) SetLoopEnd(loopEnd float32) { - n.loopEnd = loopEnd -} - -type nopOscillatorNode struct { - nopUserNode - frequency float32 -} - -func (n *nopOscillatorNode) Frequency() float32 { - return n.frequency -} - -func (n *nopOscillatorNode) SetFrequency(frequency float32) { - n.frequency = frequency -} - -type nopGainNode struct { - nopUserNode - gain float32 -} - -func (n *nopGainNode) Gain() float32 { - return n.gain -} - -func (n *nopGainNode) SetGain(gain float32) { - n.gain = gain -} - -type nopPanNode struct { - nopUserNode - pan float32 -} - -func (n *nopPanNode) Pan() float32 { - return n.pan -} - -func (n *nopPanNode) SetPan(pan float32) { - n.pan = pan -} - -type nopSpatialNode struct { - nopUserNode - position sprec.Vec3 -} - -func (n *nopSpatialNode) Position() sprec.Vec3 { - return n.position -} - -func (n *nopSpatialNode) SetPosition(position sprec.Vec3) { - n.position = position -} - -type nopHighPassNode struct { - nopUserNode - cutoffFrequency float32 -} - -func (n *nopHighPassNode) CutoffFrequency() float32 { - return n.cutoffFrequency -} - -func (n *nopHighPassNode) SetCutoffFrequency(cutoffFrequency float32) { - n.cutoffFrequency = cutoffFrequency -} - -type nopLowPassNode struct { - nopUserNode - cutoffFrequency float32 -} - -func (n *nopLowPassNode) CutoffFrequency() float32 { - return n.cutoffFrequency -} - -func (n *nopLowPassNode) SetCutoffFrequency(cutoffFrequency float32) { - n.cutoffFrequency = cutoffFrequency -} - -type nopDelayNode struct { - nopUserNode - delayTime float32 -} - -func (n *nopDelayNode) DelayTime() float32 { - return n.delayTime -} - -func (n *nopDelayNode) SetDelayTime(delayTime float32) { - n.delayTime = delayTime -} - -type nopReverbNode struct { - nopUserNode - roomSize float32 - damping float32 - dry float32 - wet float32 -} - -func (n *nopReverbNode) RoomSize() float32 { - return n.roomSize -} - -func (n *nopReverbNode) SetRoomSize(roomSize float32) { - n.roomSize = roomSize -} - -func (n *nopReverbNode) Damping() float32 { - return n.damping -} - -func (n *nopReverbNode) SetDamping(damping float32) { - n.damping = damping -} - -func (n *nopReverbNode) Dry() float32 { - return n.dry -} - -func (n *nopReverbNode) SetDry(dry float32) { - n.dry = dry -} - -func (n *nopReverbNode) Wet() float32 { - return n.wet -} - -func (n *nopReverbNode) SetWet(wet float32) { - n.wet = wet -} - -type nopCompressorNode struct { - nopUserNode - attack float32 - release float32 - ratio float32 - knee float32 - threshold float32 -} - -func (n *nopCompressorNode) Attack() float32 { - return n.attack -} - -func (n *nopCompressorNode) SetAttack(attack float32) { - n.attack = attack -} - -func (n *nopCompressorNode) Release() float32 { - return n.release -} - -func (n *nopCompressorNode) SetRelease(release float32) { - n.release = release -} - -func (n *nopCompressorNode) Ratio() float32 { - return n.ratio -} - -func (n *nopCompressorNode) SetRatio(ratio float32) { - n.ratio = ratio -} - -func (n *nopCompressorNode) Knee() float32 { - return n.knee -} - -func (n *nopCompressorNode) SetKnee(knee float32) { - n.knee = knee -} - -func (n *nopCompressorNode) Threshold() float32 { - return n.threshold -} - -func (n *nopCompressorNode) SetThreshold(threshold float32) { - n.threshold = threshold -} - -type nopPlayback struct{} - -func (p *nopPlayback) Stop() {} diff --git a/audio/playback.go b/audio/playback.go deleted file mode 100644 index 5d93689d..00000000 --- a/audio/playback.go +++ /dev/null @@ -1,26 +0,0 @@ -package audio - -import "github.com/mokiat/gog/opt" - -// PlayInfo specifies conditions for the playback. -type PlayInfo struct { - - // Loop indicates whether the playback should loop. - Loop bool - - // Gain indicates the amount of volume, where 1.0 is max and 0.0 is min. - // - // If not specified, the default value is 1.0. - Gain opt.T[float32] - - // Pan indicates the sound panning, where -1.0 is left, 0.0 is center, and - // 1.0 is right. - Pan float32 -} - -// Playback represents the audio playback of a media file. -type Playback interface { - - // Stop causes the playback to stop. - Stop() -} diff --git a/audio/util.go b/audio/util.go deleted file mode 100644 index 1677fe8e..00000000 --- a/audio/util.go +++ /dev/null @@ -1,25 +0,0 @@ -package audio - -import "math" - -// DBToGain converts a decibel value to a gain value. -func DBToGain(db float32) float32 { - return float32(math.Pow(10.0, float64(db/20.0))) -} - -// GainToDB converts a gain value to a decibel value. -func GainToDB(gain float32) float32 { - return float32(20.0 * math.Log10(float64(gain))) -} - -// SampleCount calculates the number of samples for a given duration in seconds -// given the used sample rate. -func SampleCount(seconds float32, sampleRate int) int { - return int(float32(sampleRate) * seconds) -} - -// Seconds calculates the duration in seconds for a given number of samples -// and sample rate. -func Seconds(sampleCount, sampleRate int) float32 { - return float32(sampleCount) / float32(sampleRate) -} diff --git a/audio/wav/decoder.go b/audio/wav/decoder.go deleted file mode 100644 index aca09725..00000000 --- a/audio/wav/decoder.go +++ /dev/null @@ -1,60 +0,0 @@ -package wav - -import ( - "bytes" - "io" - - "github.com/go-audio/wav" - "github.com/mokiat/lacking/audio" -) - -func init() { - magics := []string{ - "RIFF", - } - audio.RegisterFormat("wav", magics, Decode) -} - -// Decode decodes WAV data from the provided reader and returns the decoded -// audio frames. -func Decode(in io.Reader) (audio.MediaData, error) { - raw, err := io.ReadAll(in) - if err != nil { - return audio.MediaData{}, err - } - - decoder := wav.NewDecoder(bytes.NewReader(raw)) - buffer, err := decoder.FullPCMBuffer() - if err != nil { - return audio.MediaData{}, err - } - flBuffer := buffer.AsFloat32Buffer() - - length := flBuffer.NumFrames() - frames := make([]audio.Frame, length) - if buffer.Format.NumChannels > 0 { - if buffer.Format.NumChannels == 1 { - for i := range length { - value := flBuffer.Data[i] - frames[i] = audio.Frame{ - Left: value, - Right: value, - } - } - } else { - offset := 0 - for i := range length { - frames[i] = audio.Frame{ - Left: flBuffer.Data[offset+0], - Right: flBuffer.Data[offset+1], - } - offset += buffer.Format.NumChannels - } - } - } - - return audio.MediaData{ - Frames: frames, - SampleRate: buffer.Format.SampleRate, - }, nil -} From ff113b20bd2dc7307ae18b092f0d1daf894bb091 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 19:26:03 +0300 Subject: [PATCH 57/59] mkdocs: fix security issue with polyfill --- mkdocs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index d91ecb9e..fcf28136 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,5 +37,4 @@ markdown_extensions: generic: true - pymdownx.superfences: extra_javascript: - - https://polyfill.io/v3/polyfill.min.js?features=es6 - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js From 5071c0d4f55a9ecd66e1ab0b6dff98d05e16215b Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 19:55:18 +0300 Subject: [PATCH 58/59] Bump dependencies --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 04c94359..a7d7ecf8 100644 --- a/go.mod +++ b/go.mod @@ -10,12 +10,12 @@ require ( github.com/mokiat/gblob v0.6.0 github.com/mokiat/goexr v0.1.0 github.com/mokiat/gog v0.22.0 - github.com/mokiat/gomath v0.16.0 - github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.40.0 + github.com/mokiat/gomath v0.16.1 + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 github.com/qmuntal/gltf v0.28.0 github.com/x448/float16 v0.8.4 - golang.org/x/image v0.40.0 + golang.org/x/image v0.41.0 golang.org/x/sync v0.20.0 ) @@ -27,14 +27,14 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index f40b4d65..ac999e5a 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= @@ -56,18 +56,18 @@ github.com/mokiat/goexr v0.1.0 h1:zoDvzvIjs/GpkxJDVcCP6GafLp1nOuNDef9JL8KSd2A= github.com/mokiat/goexr v0.1.0/go.mod h1:KhERYaXCcY2ZEaTg1/LyzJ7pxdj/q3V1TxgViG86ck4= github.com/mokiat/gog v0.22.0 h1:LUfqgvJHpUlre5JVx10fsipHnqo5fmCEiZ2RWBlNgG4= github.com/mokiat/gog v0.22.0/go.mod h1:0tl6srnQjC9ZYKAQkvLrrXMblFMmqqjoOWLm+LkRAEo= -github.com/mokiat/gomath v0.16.0 h1:RST7h5F7+UGLs5EeOoeNVX5d2FQB50qmUYpJRmMIyi0= -github.com/mokiat/gomath v0.16.0/go.mod h1:ixaVMF1VIeH0C3oyIWEwUAxB3ggzzKgcoWZWOKe2DdU= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/mokiat/gomath v0.16.1 h1:zhVQG3Uo5O9Nzbf0VMjAM4z3T5yHUAhl8hpu2YZNXTA= +github.com/mokiat/gomath v0.16.1/go.mod h1:k/+XLNd4nQyJzoXN2xy5zTh5LpqvhW2TAs7ptMpJs4c= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= 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/qmuntal/gltf v0.28.0 h1:C4A1temWMPtcI2+qNfpfRq8FEJxoBGUN3ZZM8BCc+xU= github.com/qmuntal/gltf v0.28.0/go.mod h1:YoXZOt0Nc0kIfSKOLZIRoV4FycdC+GzE+3JgiAGYoMs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -82,19 +82,19 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a h1:H06+n8uULVXJdhbdJ9+40jLzRcAPQP2h1UXcs01jzsk= -golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= -golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= -golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b h1:E7MAoHE/7prIY6tu29UATfH3hVHv6IWqOchjE48pTAU= +golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= From 7ac02b5272d5731009d6ff378e3b786d127d0fe3 Mon Sep 17 00:00:00 2001 From: Momchil Atanasov Date: Sun, 7 Jun 2026 20:11:56 +0300 Subject: [PATCH 59/59] docs: update docs on audio --- docs/manual/audio/index.md | 295 +++++++++++++++++++------------------ 1 file changed, 154 insertions(+), 141 deletions(-) diff --git a/docs/manual/audio/index.md b/docs/manual/audio/index.md index a5c4f099..c6269d4c 100644 --- a/docs/manual/audio/index.md +++ b/docs/manual/audio/index.md @@ -4,7 +4,7 @@ title: Overview # Audio -The `audio` package defines a low-level, node-graph-based audio API. It is the audio equivalent of the `render` package: an abstract interface that is implemented separately for each platform (native desktop, web). Game code is not expected to use this API directly — a higher-level audio API will be provided for that purpose. +The `core/audio` package defines a mid-level, bus-based audio API. It is the audio equivalent of the `render` package: an abstract interface implemented separately for each platform (native desktop, web). Game code is not expected to use this API directly — a higher-level audio API is provided for that purpose — but there are cases where working directly with these primitives is necessary. The API is obtained from the application window: @@ -16,245 +16,258 @@ api := window.AudioAPI() | Concept | Description | |---|---| -| **API** | Entry point. Creates media and nodes, exposes the output node and spatial listener. | -| **Media** | A decoded audio clip loaded into memory. Acts as a data source for `PlaybackNode`. | -| **Sample** | A single stereo audio sample consisting of left and right channel values. | -| **Node** | An element in the audio graph that produces or processes a signal. | -| **UserNode** | A node that owns resources and must be explicitly deleted when no longer needed. | -| **Output** | The terminal sink node of the graph. Signals connected to it are sent to the speakers. | -| **SpatialListener** | Represents the listener's position and orientation in 3D space for spatial audio. | +| **API** | Entry point. Creates media, buses, and playback instances. Exposes the master bus and spatial listener. | +| **MediaData** | Raw decoded audio frames together with the sample rate. | +| **Media** | A decoded audio clip loaded into the audio system. Acts as a data source for playback instances. | +| **Frame** | A single stereo audio frame consisting of left and right channel values. | +| **MasterBus** | The overall output sink. Controls master gain and compression. | +| **Bus** | A named group of sound sources with collective gain, reverb, compression, and pause/resume control. | +| **Playback** | A single playing instance of a `Media` on a `Bus`. Controls start/stop/pause and per-playback properties. | +| **SpatialPlayback** | A `Playback` that is also positioned and oriented in 3D space. | +| **SpatialListener** | The listener's position and orientation in 3D space for spatial audio. | ## Media -`Media` represents a decoded audio clip held in memory. It is created from raw PCM samples or from an encoded file (WAV, MP3): +`Media` represents a decoded audio clip held in the audio system. It is created from a `MediaData` value, which holds raw PCM frames and a sample rate: ```go -// From raw PCM samples (must match the API's sample rate). -media := api.CreateMedia(samples) +// Decode from an encoded file (WAV, MP3, or any registered format). +data, _, err := audio.Decode(r) +if err != nil { + // handle error +} -// From encoded file bytes. -media := api.ParseMedia(audio.MediaInfo{ - Data: fileBytes, - DataType: audio.MediaDataTypeWAV, // or MediaDataTypeMP3, MediaDataTypeAuto -}) +// Resample if necessary (e.g. if the audio system requires a specific rate). +// data.Frames = audio.Resample(data.Frames, data.SampleRate, targetRate) + +media := api.CreateMedia(data) // Release when no longer needed. -defer media.Delete() +defer media.Release() ``` -It is safe to delete a `Media` after using it to create a `PlaybackNode` — the node retains its own reference to the underlying data. +It is safe to release a `Media` after using it to create playback instances — existing playback is not affected. -### Sample Rate +`media.Length()` returns the duration of the clip in seconds. -The API operates at a fixed sample rate, available via `api.SampleRate()`. Raw samples passed to `CreateMedia` must already be at this rate. The `Resample` utility can be used to convert: +### Frames and Sample Rate + +Audio data is represented as a slice of `Frame` values, each holding a left and right channel sample: ```go -resampled := audio.Resample(samples, originalRate, api.SampleRate()) +type Frame struct { + Left float32 + Right float32 +} ``` -`SampleCount` calculates how many samples correspond to a given duration: +The `Resample` utility converts frames between sample rates: ```go -count := audio.SampleCount(2.5, api.SampleRate()) // samples for 2.5 seconds +resampled := audio.Resample(frames, originalRate, targetRate) ``` -## Node Graph - -Audio flows through a directed graph of nodes. Sources generate signals, processing nodes transform them, and everything ultimately connects to the output node. When multiple sources are connected to the same target their signals are mixed additively. +`SampleCount` and `Seconds` convert between frame counts and durations: ```go -output := api.Output() +count := audio.SampleCount(2.5, sampleRate) // frames for 2.5 seconds +dur := audio.Seconds(count, sampleRate) // back to seconds +``` -// Connect source directly to output. -api.Connect(source, output) +### Decoding Audio Files -// Or use Chain for a linear sequence. -api.Chain(source, gainNode, reverbNode, output) +`audio.Decode` auto-detects the format by magic-byte prefix and decodes the data: -// Disconnect when done. -api.Disconnect(source, output) +```go +data, format, err := audio.Decode(r) // format is e.g. "mp3" or "wav" ``` -All nodes that are no longer needed must be deleted to avoid resource leaks: +Format decoders self-register via package `init`. Import the sub-packages to enable them: ```go -gainNode.Delete() +import ( + _ "github.com/mokiat/lacking/core/audio/mp3" + _ "github.com/mokiat/lacking/core/audio/wav" +) ``` -## Node Types +Custom decoders can be added with `audio.RegisterDecoder`. -The following node types are available: +## Master Bus -| Node | Factory | Purpose | -|---|---|---| -| `PlaybackNode` | `CreatePlaybackNode` | Plays back a `Media` clip. | -| `OscillatorNode` | `CreateOscillatorNode` | Generates a periodic waveform. | -| `GainNode` | `CreateGainNode` | Scales the signal amplitude. | -| `PanNode` | `CreatePanNode` | Pans the signal between left and right channels. | -| `SpatialNode` | `CreateSpatialNode` | Applies 3D positional audio effects. | -| `HighPassNode` | `CreateHighPassNode` | Removes frequencies below a cutoff. | -| `LowPassNode` | `CreateLowPassNode` | Removes frequencies above a cutoff. | -| `DelayNode` | `CreateDelayNode` | Adds a time delay to the signal. | -| `ReverbNode` | `CreateReverbNode` | Applies a room reverb effect. | -| `CompressorNode` | `CreateCompressorNode` | Applies dynamic range compression. | -| `ConnectorNode` | `CreateConnectorNode` | Pass-through node useful as a named connection point. | - -### PlaybackNode - -Plays back a `Media` clip. Created with an initial loop setting: +`MasterBus` is the global output sink. It controls the overall gain and provides access to global compression: ```go -node := api.CreatePlaybackNode(media, false) -defer node.Delete() - -api.Connect(node, api.Output()) +master := api.MasterBus() +master.SetGain(0.8) -node.Start(0) // start from the beginning -node.Pause() // pause; resumes from the same position -node.Resume() // resume from where it was paused -node.Stop() // stop and reset position +comp := master.Compression() +comp.SetThreshold(-18.0) +comp.SetRatio(4.0) ``` -Loop playback can be configured after creation, including a sub-range of the clip: +## Buses + +A `Bus` groups a set of sound sources for collective control. Create one with `CreateBus`, optionally enabling reverb and/or compression: ```go -node.SetLoop(true) -node.SetLoopStart(1.0) // loop from 1.0 s -node.SetLoopEnd(4.5) // to 4.5 s +musicBus := api.CreateBus(audio.BusSettings{}) +sfxBus := api.CreateBus(audio.BusSettings{UseCompression: true}) +envBus := api.CreateBus(audio.BusSettings{UseReverb: true, UseCompression: true}) +defer musicBus.Release() +defer sfxBus.Release() +defer envBus.Release() ``` -### OscillatorNode +Releasing a bus stops all playback attached to it. -Generates a continuous periodic waveform at a configurable frequency. Default frequency is 440 Hz (A4). +### Bus Controls ```go -node := api.CreateOscillatorNode() -defer node.Delete() +bus.SetGain(0.5) // half volume for everything on this bus -node.SetFrequency(220.0) // A3 -api.Connect(node, api.Output()) +// Pause/resume all sources on the bus at once. +bus.Pause() +bus.Resume() ``` -### GainNode - -Scales the signal amplitude. A gain of `1.0` is unity (no change); `0.0` is silence; values above `1.0` amplify. Default is `1.0`. +`bus.Compression()` and `bus.Reverb()` return `nil` if the bus was not created with those effects enabled. -```go -gain := api.CreateGainNode() -defer gain.Delete() +### Reverb -gain.SetGain(0.5) // half volume -api.Chain(source, gain, api.Output()) -``` +Configure room characteristics on buses created with `UseReverb: true`: -The `DBToGain` and `GainToDB` utilities convert between decibels and linear gain: +| Parameter | Default | Range | Description | +|---|---|---|---| +| `RoomSize` | 0.3 | [0.0, 1.0] | Size of the virtual room. | +| `Damping` | 0.5 | [0.0, 1.0] | High-frequency absorption. Higher values simulate softer surfaces. | +| `Dry` | 1.0 | [0.0, 1.0] | Level of the unprocessed signal. | +| `Wet` | 0.5 | [0.0, 1.0] | Level of the reverberated signal. | ```go -gain.SetGain(audio.DBToGain(-6.0)) // -6 dB +reverb := bus.Reverb() +reverb.SetRoomSize(0.8) +reverb.SetDamping(0.3) +reverb.SetWet(0.4) ``` -### PanNode +### Compression -Distributes the signal between left and right channels. The range is `[-1.0, 1.0]` where `-1.0` is full left, `0.0` is center, and `1.0` is full right. Default is `0.0`. +Available on both `Bus` (when `UseCompression: true`) and `MasterBus`: -```go -pan := api.CreatePanNode() -defer pan.Delete() +| Parameter | Default | Range | Description | +|---|---|---|---| +| `Threshold` | -24.0 dB | [-100.0, 0.0] | Level above which compression is applied. | +| `Ratio` | 12.0 | [1.0, 20.0] | Compression ratio (input dB : output dB above threshold). | +| `Knee` | 30.0 dB | [0.0, 40.0] | Width of the soft-knee transition around the threshold. | +| `Attack` | 0.003 s | [0.0, 1.0] | Time for compression to engage. | +| `Release` | 0.25 s | [0.0, 1.0] | Time for compression to disengage. | -pan.SetPan(-0.5) // slightly left -api.Chain(source, pan, api.Output()) +```go +comp := bus.Compression() +comp.SetThreshold(-18.0) +comp.SetRatio(4.0) ``` -### Filter Nodes +## Playback -`HighPassNode` and `LowPassNode` each expose a single `CutoffFrequency` parameter (in Hz). Default cutoff is 350 Hz for both. +A `Playback` is a single instance of a `Media` playing on a `Bus`. Create one with `CreatePlayback`: ```go -hp := api.CreateHighPassNode() -defer hp.Delete() -hp.SetCutoffFrequency(80.0) // remove rumble below 80 Hz +playback := api.CreatePlayback(bus, media, audio.PlaybackSettings{}) +defer playback.Release() -lp := api.CreateLowPassNode() -defer lp.Delete() -lp.SetCutoffFrequency(8000.0) // remove hiss above 8 kHz +playback.Start(0) // start from the beginning +playback.Pause() // pause; position is preserved +playback.Resume() // resume from paused position +playback.Stop() // stop and reset position ``` -### DelayNode +`playback.Playing()` reports whether the playback is currently active. -Adds a time delay to the signal. Default delay is `0.0` seconds. Implementations must support at least 1 second of delay. +### Loop Control ```go -delay := api.CreateDelayNode() -defer delay.Delete() - -delay.SetDelayTime(0.3) // 300 ms -api.Chain(source, delay, api.Output()) +playback.SetLooping(true) +playback.SetLoopStart(1.0) // loop from 1.0 s +playback.SetLoopEnd(4.5) // to 4.5 s ``` -### ReverbNode +### Per-Playback Controls -Applies a reverb effect with configurable room characteristics and dry/wet mix. +```go +playback.SetGain(0.7) // individual volume +playback.SetPlaybackRate(1.5) // 1.5× speed; pitch shifts accordingly +``` -| Parameter | Default | Range | Description | -|---|---|---|---| -| `RoomSize` | 0.3 | [0.0, 1.0] | Size of the virtual room. | -| `Damping` | 0.5 | [0.0, 1.0] | High-frequency absorption. Higher values simulate softer surfaces. | -| `Dry` | 1.0 | [0.0, 1.0] | Level of the unprocessed signal. | -| `Wet` | 0.5 | [0.0, 1.0] | Level of the reverberated signal. | +`DBToGain` and `GainToDB` convert between decibels and linear gain: ```go -reverb := api.CreateReverbNode() -defer reverb.Delete() +playback.SetGain(audio.DBToGain(-6.0)) // -6 dB +``` -reverb.SetRoomSize(0.8) -reverb.SetDamping(0.3) -reverb.SetWet(0.4) +### Completion Callback -api.Chain(source, reverb, api.Output()) +```go +playback.SetOnFinished(func() { + // called when the media plays through naturally (not on Stop or Pause, + // and not on each loop iteration) +}) ``` -### CompressorNode - -Applies dynamic range compression, attenuating signals that exceed a threshold. +### Per-Playback Filters -| Parameter | Default | Range | Description | -|---|---|---|---| -| `Threshold` | -24.0 dB | [-100.0, 0.0] | Level above which compression is applied. | -| `Ratio` | 12.0 | [1.0, 20.0] | Compression ratio (input dB : output dB above threshold). | -| `Knee` | 30.0 dB | [0.0, 40.0] | Width of the soft-knee transition around the threshold. | -| `Attack` | 0.003 s | [0.0, 1.0] | Time for compression to engage after the threshold is exceeded. | -| `Release` | 0.25 s | [0.0, 1.0] | Time for compression to disengage after the signal drops below the threshold. | +Low-pass and high-pass filters can be enabled at creation time: ```go -comp := api.CreateCompressorNode() -defer comp.Delete() +playback := api.CreatePlayback(bus, media, audio.PlaybackSettings{ + UseLowPassFilter: true, + UseHighPassFilter: true, +}) -comp.SetThreshold(-18.0) -comp.SetRatio(4.0) -api.Chain(source, comp, api.Output()) +playback.LowPassFilter().SetFrequency(8000.0) // remove hiss above 8 kHz +playback.HighPassFilter().SetFrequency(80.0) // remove rumble below 80 Hz ``` +`LowPassFilter()` and `HighPassFilter()` return `nil` if the respective filter was not enabled at creation. + ## Spatial Audio -`SpatialNode` wraps a signal source and applies 3D positional effects relative to the `SpatialListener`. The attenuation model is inverse distance: `gain = 1.0 / max(1.0, distance)`. +`CreateSpatialPlayback` returns a `SpatialPlayback`, which combines `Playback` with `SpatialEmitter`. The sound source is positioned in 3D space and attenuated relative to the `SpatialListener`. ```go -// Configure the listener (typically updated each frame to match the camera). +// Update the listener each frame to match the camera. listener := api.SpatialListener() listener.SetPosition(cameraPosition) listener.SetRotation(cameraRotation) -// Place a sound source in the world. -spatial := api.CreateSpatialNode() -defer spatial.Delete() +// Create a positioned sound source. +spatial := api.CreateSpatialPlayback(bus, media, audio.PlaybackSettings{}) +defer spatial.Release() spatial.SetPosition(sprec.Vec3{X: 10, Y: 0, Z: -5}) -api.Chain(playbackNode, spatial, api.Output()) +spatial.Start(0) +``` + +### Directional Emission (Cone) + +By default a spatial source emits equally in all directions (`OuterConeAngle` = 360°). Narrowing the cone makes the source directional: + +```go +spatial.SetInnerConeAngle(sprec.Degrees(30)) // full gain within 30° +spatial.SetOuterConeAngle(sprec.Degrees(90)) // fade to outer gain by 90° +spatial.SetOuterConeGain(0.0) // silent outside the outer cone ``` +| Property | Default | Description | +|---|---|---| +| `InnerConeAngle` | — | Within this angle the emitter plays at full gain. | +| `OuterConeAngle` | 360° | Beyond this angle the gain is `OuterConeGain`. Between inner and outer the gain is linearly interpolated. | +| `OuterConeGain` | 0.0 | Gain applied when the listener is outside the outer cone. | + ## No-op Implementation -`NewNopAPI` returns a fully functional but silent implementation. All node factories return working objects that store and return their configured values; no audio is produced. This is useful for headless environments and tests: +`NewNopAPI` returns a fully functional but silent implementation. All methods work correctly and return valid objects; no audio is produced. Useful for headless environments and tests: ```go api := audio.NewNopAPI()