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/.gitignore b/.gitignore index f288d6aa..de32c738 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ # Visual Studio Code .vscode/ + +# PProf +cpu.pprof +mem.pprof diff --git a/app/controller.go b/app/controller.go index c0d12a89..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. @@ -104,6 +107,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 998ec7e3..1cb6f35d 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 @@ -28,7 +30,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. @@ -37,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. @@ -62,6 +66,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. @@ -85,33 +92,37 @@ const ( // GamepadActionStickMove indicates that a gamepad stick or trigger was moved. GamepadActionStickMove -) -// GamepadAction is used to specify the type of gamepad action that occurred. -type GamepadAction int + // GamepadActionCount is a sentinel value representing the total number of + // gamepad action enums. + GamepadActionCount +) -// 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: - 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" } } +// GamepadButton represents the gamepad button. +type GamepadButton int + const ( // GamepadButtonNone indicates that no button is associated with the event. GamepadButtonNone GamepadButton = iota @@ -175,85 +186,91 @@ 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 ) -// GamepadButton represents the gamepad button. -type GamepadButton int - +// String returns a string representation of this button. 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" } } +// 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 @@ -264,40 +281,38 @@ 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. + // GamepadStickCount is a sentinel value representing the total number of + // gamepad stick enums. 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 { 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" } } -// 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,17 +356,17 @@ 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 // 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. @@ -372,7 +387,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. @@ -384,14 +399,15 @@ 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 - // 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. diff --git a/app/keyboard.go b/app/keyboard.go index 025c558e..ff6f1395 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 } @@ -25,9 +25,15 @@ func (e KeyboardEvent) String() string { ) } +// KeyboardAction is used to specify the type of keyboard action that occurred. +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 @@ -42,14 +48,17 @@ const ( // might be the result of modifiers or special keys that would be hard // to reconstruct from just the key code. KeyboardActionType -) -// KeyboardAction is used to specify the type of keyboard action that occurred. -type KeyboardAction int + // KeyboardActionCount is a sentinel value representing the total number of + // keyboard action enums. + KeyboardActionCount +) -// 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 KeyboardActionNone: + return "NONE" case KeyboardActionDown: return "DOWN" case KeyboardActionUp: @@ -63,98 +72,189 @@ func (a KeyboardAction) String() string { } } +// KeyCode represents a keyboard key. +type KeyCode int + const ( - KeyCodeEscape KeyCode = 1 + iota + // KeyCodeNone indicates that no key is associated with the event. + KeyCodeNone KeyCode = iota + // KeyCodeEscape indicates the Escape key. + KeyCodeEscape + // 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 -) -// KeyCode represents a keyboard key. -type KeyCode int + // 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: @@ -166,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: @@ -192,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 04fdd59a..3580843d 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 @@ -48,9 +48,15 @@ func (e MouseEvent) String() string { ) } +// MouseAction represents the type of action performed with the mouse. +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 @@ -75,58 +81,70 @@ const ( // The ScrollX and ScrollY values of the event indicate the offset in // abstract units (comparable to pixels in magnitude). MouseActionScroll -) -// MouseAction represents the type of action performed with the mouse. -type MouseAction int + // 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" } } +// MouseButton represents the mouse button. +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 -) -// MouseButton represents the mouse button. -type MouseButton int + // 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" } } 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..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" ) @@ -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,13 +59,17 @@ 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 // 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) diff --git a/audio/api.go b/audio/api.go deleted file mode 100644 index e5b2d935..00000000 --- a/audio/api.go +++ /dev/null @@ -1,11 +0,0 @@ -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 - - // Play plays the specified media as soon as possible. - Play(media Media, info PlayInfo) Playback -} diff --git a/audio/doc.go b/audio/doc.go deleted file mode 100644 index 389af1a2..00000000 --- a/audio/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package audio provides audio processing and playback functionality. -package audio diff --git a/audio/media.go b/audio/media.go deleted file mode 100644 index 3d287528..00000000 --- a/audio/media.go +++ /dev/null @@ -1,38 +0,0 @@ -package audio - -import "time" - -// MediaDataType indicates the type of media data contained in a data block. -type MediaDataType int8 - -const ( - // MediaDataTypeAuto indicates that the media data type should be - // automatically detected based on the data. - MediaDataTypeAuto MediaDataType = iota - - // 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 -} - -// 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/nop.go b/audio/nop.go deleted file mode 100644 index e13890c3..00000000 --- a/audio/nop.go +++ /dev/null @@ -1,30 +0,0 @@ -package audio - -import "time" - -// NewNopAPI returns an API that does nothing. -func NewNopAPI() API { - return &nopAPI{} -} - -type nopAPI struct{} - -func (a *nopAPI) CreateMedia(info MediaInfo) Media { - return &nopMedia{} -} - -func (a *nopAPI) Play(media Media, info PlayInfo) Playback { - return &nopPlayback{} -} - -type nopMedia struct{} - -func (m *nopMedia) Length() time.Duration { - return time.Millisecond -} - -func (m *nopMedia) Delete() {} - -type nopPlayback struct{} - -func (p *nopPlayback) Stop() {} diff --git a/audio/playback.go b/audio/playback.go deleted file mode 100644 index bf4ecba4..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[float64] - - // Pan indicates the sound panning, where -1.0 is left, 0.0 is center, and - // 1.0 is right. - Pan float64 -} - -// Playback represents the audio playback of a media file. -type Playback interface { - - // Stop causes the playback to stop. - Stop() -} diff --git a/core/audio/api.go b/core/audio/api.go new file mode 100644 index 00000000..deab12a2 --- /dev/null +++ b/core/audio/api.go @@ -0,0 +1,42 @@ +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 { + + // 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 + + // 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..f39ce631 --- /dev/null +++ b/core/audio/bus.go @@ -0,0 +1,50 @@ +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. + // + // The value must be non-negative. + SetGain(gain float32) + + // 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() + + // 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. + 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/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/filter.go b/core/audio/filter.go new file mode 100644 index 00000000..e8a64a1b --- /dev/null +++ b/core/audio/filter.go @@ -0,0 +1,114 @@ +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) +} + +// 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/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..8f284ac4 --- /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() float64 + + // 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..b6ea1d7d --- /dev/null +++ b/core/audio/mp3/decoder.go @@ -0,0 +1,58 @@ +package mp3 + +import ( + "io" + "math" + + "github.com/hajimehoshi/go-mp3" + "github.com/mokiat/gblob" + "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) { + 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/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/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 +} diff --git a/core/audio/playback.go b/core/audio/playback.go new file mode 100644 index 00000000..029db211 --- /dev/null +++ b/core/audio/playback.go @@ -0,0 +1,105 @@ +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, 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 float64) + + // 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() float64 + + // SetLoopStart sets the starting point in seconds from the beginning of the media where looping should occur. + 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() float64 + + // SetLoopEnd sets the ending point in seconds from the beginning of the media where looping should occur. + SetLoopEnd(loopEnd float64) + + // Playing returns true if the media is currently playing, false otherwise. + 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. + // + // Default value is 1.0, which means no change in volume. + 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, + // 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 + + // 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() +} + +// 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 +} diff --git a/core/audio/spatial.go b/core/audio/spatial.go new file mode 100644 index 00000000..66e26c8b --- /dev/null +++ b/core/audio/spatial.go @@ -0,0 +1,60 @@ +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) +} + +// 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) + + // 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. + 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/util.go b/core/audio/util.go new file mode 100644 index 00000000..979b0779 --- /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 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) float64 { + return float64(sampleCount) / float64(sampleRate) +} diff --git a/core/audio/wav/decoder.go b/core/audio/wav/decoder.go new file mode 100644 index 00000000..aaf9d822 --- /dev/null +++ b/core/audio/wav/decoder.go @@ -0,0 +1,57 @@ +package wav + +import ( + "bytes" + "io" + + "github.com/go-audio/wav" + "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) { + 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 +} 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 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}'$ | 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/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 21b26972..20f548a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,15 +2,15 @@ ![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. 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/docs/manual/application/index.md b/docs/manual/application/index.md index 664ff55c..e5664a53 100644 --- a/docs/manual/application/index.md +++ b/docs/manual/application/index.md @@ -4,18 +4,18 @@ 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, 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 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. +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 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. +![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/audio/index.md b/docs/manual/audio/index.md new file mode 100644 index 00000000..c6269d4c --- /dev/null +++ b/docs/manual/audio/index.md @@ -0,0 +1,274 @@ +--- +title: Overview +--- + +# Audio + +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: + +```go +api := window.AudioAPI() +``` + +## Core Concepts + +| Concept | Description | +|---|---| +| **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 the audio system. It is created from a `MediaData` value, which holds raw PCM frames and a sample rate: + +```go +// Decode from an encoded file (WAV, MP3, or any registered format). +data, _, err := audio.Decode(r) +if err != nil { + // handle error +} + +// 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.Release() +``` + +It is safe to release a `Media` after using it to create playback instances — existing playback is not affected. + +`media.Length()` returns the duration of the clip in seconds. + +### Frames and Sample Rate + +Audio data is represented as a slice of `Frame` values, each holding a left and right channel sample: + +```go +type Frame struct { + Left float32 + Right float32 +} +``` + +The `Resample` utility converts frames between sample rates: + +```go +resampled := audio.Resample(frames, originalRate, targetRate) +``` + +`SampleCount` and `Seconds` convert between frame counts and durations: + +```go +count := audio.SampleCount(2.5, sampleRate) // frames for 2.5 seconds +dur := audio.Seconds(count, sampleRate) // back to seconds +``` + +### Decoding Audio Files + +`audio.Decode` auto-detects the format by magic-byte prefix and decodes the data: + +```go +data, format, err := audio.Decode(r) // format is e.g. "mp3" or "wav" +``` + +Format decoders self-register via package `init`. Import the sub-packages to enable them: + +```go +import ( + _ "github.com/mokiat/lacking/core/audio/mp3" + _ "github.com/mokiat/lacking/core/audio/wav" +) +``` + +Custom decoders can be added with `audio.RegisterDecoder`. + +## Master Bus + +`MasterBus` is the global output sink. It controls the overall gain and provides access to global compression: + +```go +master := api.MasterBus() +master.SetGain(0.8) + +comp := master.Compression() +comp.SetThreshold(-18.0) +comp.SetRatio(4.0) +``` + +## Buses + +A `Bus` groups a set of sound sources for collective control. Create one with `CreateBus`, optionally enabling reverb and/or compression: + +```go +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() +``` + +Releasing a bus stops all playback attached to it. + +### Bus Controls + +```go +bus.SetGain(0.5) // half volume for everything on this bus + +// Pause/resume all sources on the bus at once. +bus.Pause() +bus.Resume() +``` + +`bus.Compression()` and `bus.Reverb()` return `nil` if the bus was not created with those effects enabled. + +### Reverb + +Configure room characteristics on buses created with `UseReverb: true`: + +| 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 := bus.Reverb() +reverb.SetRoomSize(0.8) +reverb.SetDamping(0.3) +reverb.SetWet(0.4) +``` + +### Compression + +Available on both `Bus` (when `UseCompression: true`) and `MasterBus`: + +| 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. | + +```go +comp := bus.Compression() +comp.SetThreshold(-18.0) +comp.SetRatio(4.0) +``` + +## Playback + +A `Playback` is a single instance of a `Media` playing on a `Bus`. Create one with `CreatePlayback`: + +```go +playback := api.CreatePlayback(bus, media, audio.PlaybackSettings{}) +defer playback.Release() + +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 +``` + +`playback.Playing()` reports whether the playback is currently active. + +### Loop Control + +```go +playback.SetLooping(true) +playback.SetLoopStart(1.0) // loop from 1.0 s +playback.SetLoopEnd(4.5) // to 4.5 s +``` + +### Per-Playback Controls + +```go +playback.SetGain(0.7) // individual volume +playback.SetPlaybackRate(1.5) // 1.5× speed; pitch shifts accordingly +``` + +`DBToGain` and `GainToDB` convert between decibels and linear gain: + +```go +playback.SetGain(audio.DBToGain(-6.0)) // -6 dB +``` + +### Completion Callback + +```go +playback.SetOnFinished(func() { + // called when the media plays through naturally (not on Stop or Pause, + // and not on each loop iteration) +}) +``` + +### Per-Playback Filters + +Low-pass and high-pass filters can be enabled at creation time: + +```go +playback := api.CreatePlayback(bus, media, audio.PlaybackSettings{ + UseLowPassFilter: true, + UseHighPassFilter: true, +}) + +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 + +`CreateSpatialPlayback` returns a `SpatialPlayback`, which combines `Playback` with `SpatialEmitter`. The sound source is positioned in 3D space and attenuated relative to the `SpatialListener`. + +```go +// Update the listener each frame to match the camera. +listener := api.SpatialListener() +listener.SetPosition(cameraPosition) +listener.SetRotation(cameraRotation) + +// 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}) +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 methods work correctly and return valid objects; no audio is produced. Useful for headless environments and tests: + +```go +api := audio.NewNopAPI() +``` diff --git a/docs/manual/ecs/index.md b/docs/manual/ecs/index.md new file mode 100644 index 00000000..01e5a5de --- /dev/null +++ b/docs/manual/ecs/index.md @@ -0,0 +1,354 @@ +--- +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.SetComponent(op, PositionType, Position{X: 1, Y: 0, Z: 0}) + ecs.SetComponent(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. Two operations are available: + +| Function | 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. No-op if the entity does not have one of that type. | + +```go +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(op, HealthType, Health{Current: 100, Max: 100}) +}) + +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(op, VelocityType, Velocity{X: 0, Y: 10, Z: 0}) +}) + +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, VelocityType) +}) +``` + +Multiple operations can be staged in a single `EditEntity` call: + +```go +scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, VelocityType) + ecs.SetComponent(op, HealthType, Health{Current: 50, Max: 100}) +}) +``` + +> 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 + +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.SetComponent(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/docs/manual/graphics/shader.md b/docs/manual/graphics/shader.md index 3a406046..21826585 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). @@ -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/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. 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). 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) +} 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/algorithm.go b/game/asset/dsl/algorithm.go index a8461582..959ed316 100644 --- a/game/asset/dsl/algorithm.go +++ b/game/asset/dsl/algorithm.go @@ -3,28 +3,42 @@ 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()) + 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 } 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 +48,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 +69,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 +99,87 @@ 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) +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 holder.Gen == nil { + + 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) { 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/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/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/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/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 +} 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/controller.go b/game/controller.go index a7bb9bd9..c1d6f058 100644 --- a/game/controller.go +++ b/game/controller.go @@ -3,10 +3,9 @@ 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/storage/chunked" + "github.com/mokiat/lacking/resource" "github.com/mokiat/lacking/util/async" ) @@ -15,9 +14,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,16 +30,13 @@ var _ app.Controller = (*Controller)(nil) type Controller struct { app.NopController - storage chunked.Storage + store resource.Store shaders graphics.ShaderCollection shaderBuilder graphics.ShaderBuilder gfxOptions []graphics.Option gfxEngine *graphics.Engine - ecsOptions []ecs.Option - ecsEngine *ecs.Engine - physicsOptions []physics.Option physicsEngine *physics.Engine @@ -51,9 +47,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 @@ -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) @@ -97,9 +85,8 @@ 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), ) c.engine.Create() diff --git a/game/ecs/bench_test.go b/game/ecs/bench_test.go index d1f82480..e8f42777 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.SetComponent(op, positionType, Position{ + X: 1.0, + Y: 2.0, }) - if i%2 == 0 { - ageComponents.Set(entity, AgeComponent{ - age: i, - }) + ecs.SetComponent(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.SetComponent(op, positionType, Position{ + X: 1.0, + Y: 2.0, + }) + ecs.SetComponent(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.SetComponent(op, nameType, Name{ + Value: "Alice", + }) + }) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(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.SetComponent(op, positionType, Position{X: 1.0, Y: 2.0}) + ecs.SetComponent(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.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.SetComponent(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..fb97bf89 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 [SetComponent], [UnsetComponent], 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 +// [SetComponent], [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..0c489d27 --- /dev/null +++ b/game/ecs/internal/command.go @@ -0,0 +1,39 @@ +package internal + +type CommandHeader struct { + CommandType CommandType +} + +const ( + CommandTypeNone CommandType = iota + CommandTypeEndOfSequence + CommandTypeCreateEntity + CommandTypeEditEntity + CommandTypeDeleteEntity + CommandTypeSetComponent + CommandTypeUnsetComponent +) + +type CommandType uint32 + +type CreateEntityCommand struct { + EntityID ID + StageRow Row +} + +type EditEntityCommand struct { + EntityID ID + StageRow Row +} + +type DeleteEntityCommand struct { + EntityID ID +} + +type SetComponentCommand struct { + TypeID TypeID +} + +type UnsetComponentCommand 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..b4f31705 --- /dev/null +++ b/game/ecs/operation.go @@ -0,0 +1,81 @@ +package ecs + +import "github.com/mokiat/lacking/game/ecs/internal" + +// EditOperation is the write handle passed to [Scene.EditEntity] and +// [Scene.CreateEntity] callbacks. Use [SetComponent] and [UnsetComponent] +// 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) + +// 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.CommandTypeSetComponent, + }) + internal.WriteToBuffer(op.commandBuffer, internal.SetComponentCommand{ + TypeID: compType.id, + }) +} + +// 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.CommandTypeUnsetComponent, + }) + internal.WriteToBuffer(op.commandBuffer, internal.UnsetComponentCommand{ + 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..43083b32 100644 --- a/game/ecs/scene.go +++ b/game/ecs/scene.go @@ -1,175 +1,644 @@ 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 [SetComponent] and [UnsetComponent] inside fn to stage +// structural or value changes. +// +// [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. +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.CommandTypeSetComponent: + cmd := internal.ReadFromBuffer[internal.SetComponentCommand](s.commandBuffer) + mask.AddType(cmd.TypeID) + changes.AddType(cmd.TypeID) + + case internal.CommandTypeUnsetComponent: + cmd := internal.ReadFromBuffer[internal.UnsetComponentCommand](s.commandBuffer) + mask.RemoveType(cmd.TypeID) + changes.RemoveType(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..e4b03b86 --- /dev/null +++ b/game/ecs/scene_test.go @@ -0,0 +1,1061 @@ +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 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.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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(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.SetComponent(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.SetComponent(op, positionType, pos) + ecs.SetComponent(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.UnsetComponent(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("SetComponent on an existing component replaces its value", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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("SetComponent twice in one edit keeps the last value", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + 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()) + + 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("setting and unsetting the same component in the same edit is a no-op", func() { + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(op, ageType, Age{Value: 42}) + ecs.UnsetComponent(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.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.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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, positionType) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(compositeEntered).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + entered = nil + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, positionType) + ecs.SetComponent(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.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(entered).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(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.SetComponent(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.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.UnsetComponent(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.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + }) + Expect(exited).To(BeEmpty()) + + scene.EditEntity(id, func(op *ecs.EditOperation) { + ecs.UnsetComponent(op, positionType) + ecs.SetComponent(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.SetComponent(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.SetComponent(op, positionType, Position{X: 1, Y: 2}) + ecs.SetComponent(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) + }) + // 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.SetComponent(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.SetComponent(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.SetComponent(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.UnsetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(op, ageType, Age{Value: 42}) + }) + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(op, ageType, Age{Value: 99}) + }) + }) + + triggerID := scene.CreateEntity(nil) + scene.EditEntity(triggerID, func(op *ecs.EditOperation) { + ecs.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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.SetComponent(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 93530f0d..23512d46 100644 --- a/game/engine.go +++ b/game/engine.go @@ -3,19 +3,18 @@ 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" - "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 } } @@ -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(), @@ -56,18 +49,17 @@ 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 gfxEngine *graphics.Engine - ecsEngine *ecs.Engine registry *resourceRegistry @@ -85,8 +77,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 { @@ -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/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/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) { 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/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/go.mod b/go.mod index 9a42dfa2..a7d7ecf8 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,44 @@ module github.com/mokiat/lacking -go 1.25 +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.5.0 + github.com/mokiat/gblob v0.6.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/gog v0.22.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.33.0 - golang.org/x/sync v0.18.0 + golang.org/x/image v0.41.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-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 - github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // 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.15.0 // 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/tools/go/expect v0.1.1-deprecated // 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.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 - 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 4858dfe7..ac999e5a 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= @@ -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= @@ -20,10 +26,13 @@ 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-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= +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= @@ -33,32 +42,32 @@ 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.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/gog v0.22.0 h1:LUfqgvJHpUlre5JVx10fsipHnqo5fmCEiZ2RWBlNgG4= +github.com/mokiat/gog v0.22.0/go.mod h1:0tl6srnQjC9ZYKAQkvLrrXMblFMmqqjoOWLm+LkRAEo= +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= @@ -73,22 +82,23 @@ 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.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/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.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.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= +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 +108,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= diff --git a/mkdocs.yml b/mkdocs.yml index 2e8c8c3e..fcf28136 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,9 +3,38 @@ site_url: http://mokiat.com/lacking repo_url: https://github.com/mokiat/lacking/ theme: name: material +nav: + - Home: index.md + - Examples: examples.md + - 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: + - Shader: manual/graphics/shader.md + - Rendering: manual/rendering/index.md + - User Interface: manual/user-interface/index.md + - Internals: + - 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-inertia.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 + - 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 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/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/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/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/ui/resource_manager.go b/ui/resource_manager.go index 062289af..35a79dca 100644 --- a/ui/resource_manager.go +++ b/ui/resource_manager.go @@ -3,17 +3,18 @@ package ui import ( "fmt" "image" - "io" - _ "image/jpeg" _ "image/png" + "io" - "github.com/mokiat/lacking/audio" - "github.com/mokiat/lacking/util/resource" + "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" ) -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 +24,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 +35,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 +53,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 +88,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) } @@ -105,21 +106,21 @@ 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(media) +} + 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) } 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.CreateMedia(audio.MediaInfo{ - Data: data, - DataType: audio.MediaDataTypeAuto, - }) - return newSound(m.audioAPI, media), nil + return m.CreateSound(data), nil } 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/ui/sound.go b/ui/sound.go index 2dab239a..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 = 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() float64 { - return globalAudioGain +func (m *Mixer) Volume() float32 { + gain := max(0.0, m.Gain()) + return sprec.Pow(gain, 1.0/2.0) } -func SetGlobalAudioGain(gain float64) { - 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 float64) { - 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 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() } 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) -}