From b1bf6993d289af839d69e998e0a513e70bcb41ba Mon Sep 17 00:00:00 2001 From: dknuth Date: Sat, 10 Oct 2020 19:49:17 -0700 Subject: [PATCH] eminary ECS Implementation work Added a implementation of an Entity Component System (ECS) architecture --- d2common/d2enum/file_types.go | 23 ++ d2common/d2events/d2event_test.go | 91 ++++++++ d2common/d2events/d2events.go | 11 + d2common/d2events/event_emitter.go | 133 ++++++++++++ d2common/d2events/event_listener.go | 6 + d2core/d2asset/asset_manager_test.go | 39 ++++ d2core/d2components/all_component_ids.go | 27 +++ d2core/d2components/asset_string_table.go | 4 + d2core/d2components/file_handle.go | 101 +++++++++ d2core/d2components/file_path.go | 99 +++++++++ d2core/d2components/file_source.go | 117 ++++++++++ d2core/d2components/file_type.go | 101 +++++++++ d2core/d2components/game_config.go | 172 +++++++++++++++ d2core/d2components/position.go | 102 +++++++++ d2core/d2components/velocity.go | 102 +++++++++ d2core/d2records/component_codes_record.go | 2 +- d2core/d2systems/asset_loader.go | 204 ++++++++++++++++++ d2core/d2systems/file_handle_resolver.go | 78 +++++++ d2core/d2systems/file_handle_resolver_test.go | 87 ++++++++ d2core/d2systems/file_source_resolver.go | 150 +++++++++++++ d2core/d2systems/file_source_resolver_test.go | 44 ++++ d2core/d2systems/file_type_resolver.go | 128 +++++++++++ d2core/d2systems/file_type_resolver_test.go | 66 ++++++ d2core/d2systems/game_config.go | 59 +++++ d2core/d2systems/integration_test.go | 57 +++++ d2core/d2systems/movement.go | 75 +++++++ d2core/d2systems/movement_test.go | 154 +++++++++++++ d2core/d2systems/testdata/testfile_a.txt | 1 + d2core/d2systems/timescale.go | 46 ++++ d2core/d2systems/timescale_test.go | 41 ++++ 30 files changed, 2319 insertions(+), 1 deletion(-) create mode 100644 d2common/d2enum/file_types.go create mode 100644 d2common/d2events/d2event_test.go create mode 100644 d2common/d2events/d2events.go create mode 100644 d2common/d2events/event_emitter.go create mode 100644 d2common/d2events/event_listener.go create mode 100644 d2core/d2asset/asset_manager_test.go create mode 100644 d2core/d2components/all_component_ids.go create mode 100644 d2core/d2components/asset_string_table.go create mode 100644 d2core/d2components/file_handle.go create mode 100644 d2core/d2components/file_path.go create mode 100644 d2core/d2components/file_source.go create mode 100644 d2core/d2components/file_type.go create mode 100644 d2core/d2components/game_config.go create mode 100644 d2core/d2components/position.go create mode 100644 d2core/d2components/velocity.go create mode 100644 d2core/d2systems/asset_loader.go create mode 100644 d2core/d2systems/file_handle_resolver.go create mode 100644 d2core/d2systems/file_handle_resolver_test.go create mode 100644 d2core/d2systems/file_source_resolver.go create mode 100644 d2core/d2systems/file_source_resolver_test.go create mode 100644 d2core/d2systems/file_type_resolver.go create mode 100644 d2core/d2systems/file_type_resolver_test.go create mode 100644 d2core/d2systems/game_config.go create mode 100644 d2core/d2systems/integration_test.go create mode 100644 d2core/d2systems/movement.go create mode 100644 d2core/d2systems/movement_test.go create mode 100644 d2core/d2systems/testdata/testfile_a.txt create mode 100644 d2core/d2systems/timescale.go create mode 100644 d2core/d2systems/timescale_test.go diff --git a/d2common/d2enum/file_types.go b/d2common/d2enum/file_types.go new file mode 100644 index 00000000..9707db9f --- /dev/null +++ b/d2common/d2enum/file_types.go @@ -0,0 +1,23 @@ +package d2enum + +// FileType represents the type of an asset +type FileType int + +// File types +const ( + FileTypeUnknown FileType = iota + FileTypeDirectory + FileTypeMPQ + FileTypeJSON + FileTypeStringTable + FileTypeDataDictionary + FileTypePalette + FileTypePaletteTransform + FileTypeCOF + FileTypeDC6 + FileTypeDCC + FileTypeDS1 + FileTypeDT1 + FileTypeWAV + FileTypeD2 +) diff --git a/d2common/d2events/d2event_test.go b/d2common/d2events/d2event_test.go new file mode 100644 index 00000000..3e1cb46f --- /dev/null +++ b/d2common/d2events/d2event_test.go @@ -0,0 +1,91 @@ +package d2events + +import ( + "math/rand" + "sync" + "testing" + "time" +) + +func Test_EventEmitter_On(t *testing.T) { + rand.Seed(time.Now().UnixNano()) + + ee := NewEventEmitter() + + eventX := "x only" + eventY := "y only" + eventBoth := "both" + + var x, y int + + ee.On(eventX, func(args ...interface{}) { + x++ + }) + + ee.On(eventY, func(args ...interface{}) { + y++ + }) + + ee.On(eventBoth, func(args ...interface{}) { + ee.Emit(eventX) + ee.Emit(eventY) + }) + + ee.Emit(eventX) + + if x != 1 { + t.Error("listener function not called") + } + + if y != 0 { + t.Error("listener function incorrectly called") + } + + ee.Emit(eventY) + ee.Emit(eventY) + + if x != 1 { + t.Error("listener function incorrectly called") + } + + if y != 2 { + t.Error("listener function not called") + } + + ee.Emit(eventBoth) + + if x != 2 { + t.Error("listener function not called") + } + + if y != 3 { + t.Error("listener function not called") + } +} + +func Benchmark_EventEmitter(b *testing.B) { + rand.Seed(time.Now().UnixNano()) + + ee := NewEventEmitter() + + e1 := "testing" + + wg := &sync.WaitGroup{} + + for idx := 0; idx < b.N; idx++ { + fn := func(args ...interface{}) { + args[0].(*sync.WaitGroup).Done() + } + + ee.Once(e1, fn) + wg.Add(1) + } + + ee.Emit(e1, wg) + + wg.Wait() + + if len(ee.listeners) > 0 { + b.Error("listener count should be 0") + } +} diff --git a/d2common/d2events/d2events.go b/d2common/d2events/d2events.go new file mode 100644 index 00000000..d3be7bf0 --- /dev/null +++ b/d2common/d2events/d2events.go @@ -0,0 +1,11 @@ +package d2events + +// NewEventEmitter initializes and returns an EventEmitter instance +func NewEventEmitter() *EventEmitter { + ee := &EventEmitter{ + listeners: make(map[string][]*EventListener), + count: 0, + } + + return ee +} diff --git a/d2common/d2events/event_emitter.go b/d2common/d2events/event_emitter.go new file mode 100644 index 00000000..df1489dc --- /dev/null +++ b/d2common/d2events/event_emitter.go @@ -0,0 +1,133 @@ +package d2events + +type EventEmitter struct { + listeners map[string][]*EventListener + count int +} + +func (ee *EventEmitter) Emit(event string, args ...interface{}) { + listeners := ee.listeners[event] + + if listeners == nil { + return + } + + for idx := range listeners { + if listeners[idx].fn != nil { + listeners[idx].fn(args...) + } + + if listeners[idx].once { + listeners = append(listeners[:idx], listeners[idx+1:]...) + } + } +} + +func (ee *EventEmitter) On(event string, fn func(...interface{})) { + ee.addListener(event, fn, false) +} + +func (ee *EventEmitter) Off(event string, fn func(...interface{})) { + ee.removeListener(event, fn) +} + +func (ee *EventEmitter) Once(event string, fn func(...interface{})) { + ee.addListener(event, fn, true) +} + +func (ee *EventEmitter) addListener(event string, fn func(...interface{}), once bool) *EventEmitter { + if fn == nil { + return ee + } + + listener := &EventListener{fn, once} + + if ee.listeners[event] == nil { + ee.listeners[event] = []*EventListener{listener} + } else { + ee.listeners[event] = append(ee.listeners[event], listener) + } + + return ee +} + +func (ee *EventEmitter) removeListener(event string, fn func(...interface{})) { + listeners := ee.listeners[event] + + if listeners == nil { + return + } + + for idx := range listeners { + listenerFn := &listeners[idx].fn + removeFn := &fn + if listenerFn == removeFn { + ee.listeners[event] = append(listeners[:idx], listeners[idx+1:]...) + } + } +} + +func (ee *EventEmitter) eventNames() []string { + names := make([]string, len(ee.listeners)) + + idx := 0 + + for event := range ee.listeners { + names[idx] = event + idx++ + } + + return names +} + +func (ee *EventEmitter) clearEvent(event string) { + ee.count-- + if ee.count <= 0 { + ee.count = 0 + ee.listeners = make(map[string][]*EventListener) + + return + } + + delete(ee.listeners, event) +} + +func (ee *EventEmitter) getHandlers(event string) []func(...interface{}) { + handlers := make([]func(...interface{}), 0) + listeners := ee.listeners[event] + + if listeners != nil { + for idx := range listeners { + handlers = append(handlers, listeners[idx].fn) + } + } + + return handlers +} + +func (ee *EventEmitter) getHandlerCount(event string) int { + if ee.listeners == nil { + return 0 + } + + listeners := ee.listeners[event] + if listeners == nil { + return 0 + } + + return len(listeners) +} + +func (ee *EventEmitter) removeAllListeners(events ...string) { + if events != nil { + if len(events) > 0 { + for idx := range events { + ee.clearEvent(events[idx]) + } + } + + return + } + + ee.listeners = make(map[string][]*EventListener, 0) +} diff --git a/d2common/d2events/event_listener.go b/d2common/d2events/event_listener.go new file mode 100644 index 00000000..c94c4982 --- /dev/null +++ b/d2common/d2events/event_listener.go @@ -0,0 +1,6 @@ +package d2events + +type EventListener struct { + fn func(...interface{}) + once bool +} diff --git a/d2core/d2asset/asset_manager_test.go b/d2core/d2asset/asset_manager_test.go new file mode 100644 index 00000000..d84a9904 --- /dev/null +++ b/d2core/d2asset/asset_manager_test.go @@ -0,0 +1,39 @@ +package d2asset + +import ( + "testing" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2cache" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2loader" +) + +func TestAssetManager_LoadFile_NoSource(t *testing.T) { + am := &AssetManager{ + loader: d2loader.NewLoader(nil), + tables: d2cache.CreateCache(tableBudget), + animations: d2cache.CreateCache(animationBudget), + fonts: d2cache.CreateCache(fontBudget), + palettes: d2cache.CreateCache(paletteBudget), + transforms: d2cache.CreateCache(paletteTransformBudget), + } + + _, err := am.LoadFile("an/invalid/path") + if err == nil { + t.Error("asset manager loaded a file for which there is no source") + } +} + +func BenchmarkAssetManager_LoadFile_NoSource(b *testing.B) { + am := &AssetManager{ + loader: d2loader.NewLoader(nil), + tables: d2cache.CreateCache(tableBudget), + animations: d2cache.CreateCache(animationBudget), + fonts: d2cache.CreateCache(fontBudget), + palettes: d2cache.CreateCache(paletteBudget), + transforms: d2cache.CreateCache(paletteTransformBudget), + } + + for idx := 0; idx < b.N; idx++ { + _, _ = am.LoadFile("an/invalid/path") + } +} diff --git a/d2core/d2components/all_component_ids.go b/d2core/d2components/all_component_ids.go new file mode 100644 index 00000000..9fcce3d6 --- /dev/null +++ b/d2core/d2components/all_component_ids.go @@ -0,0 +1,27 @@ +package d2components + +import ( + "github.com/gravestench/ecs" +) + +// Component type ID's +const ( + GameConfigCID ecs.ComponentID = iota + FilePathCID + FileTypeCID + FileSourceCID + FileHandleCID + AssetStringTableCID + AssetDataDictionaryCID + AssetPaletteCID + AssetPaletteTransformCID + AssetCofCID + AssetDc6CID + AssetDccCID + AssetDs1CID + AssetDt1CID + AssetWavCID + AssetD2CID + PositionCID + VelocityCID +) diff --git a/d2core/d2components/asset_string_table.go b/d2core/d2components/asset_string_table.go new file mode 100644 index 00000000..f0db1b26 --- /dev/null +++ b/d2core/d2components/asset_string_table.go @@ -0,0 +1,4 @@ +package d2components + +type StringTableAsset struct { +} diff --git a/d2core/d2components/file_handle.go b/d2core/d2components/file_handle.go new file mode 100644 index 00000000..ebf61b39 --- /dev/null +++ b/d2core/d2components/file_handle.go @@ -0,0 +1,101 @@ +package d2components + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" +) + +// static check that FileHandleComponent implements Component +var _ ecs.Component = &FileHandleComponent{} + +// static check that FileHandleMap implements ComponentMap +var _ ecs.ComponentMap = &FileHandleMap{} + +// FileHandleComponent is a component that contains a data stream +type FileHandleComponent struct { + Data d2interface.DataStream +} + +// ID returns a unique identifier for the component type +func (*FileHandleComponent) ID() ecs.ComponentID { + return FileHandleCID +} + +// NewMap returns a new component map the component type +func (*FileHandleComponent) NewMap() ecs.ComponentMap { + return NewFileHandleMap() +} + +// FileHandle is a convenient reference to be used as a component identifier +var FileHandle = (*FileHandleComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewFileHandleMap creates a new map of entity ID's to FileHandleComponent components +func NewFileHandleMap() *FileHandleMap { + cm := &FileHandleMap{ + components: make(map[ecs.EID]*FileHandleComponent), + } + + return cm +} + +// FileHandleMap is a map of entity ID's to FileHandleComponent components +type FileHandleMap struct { + world *ecs.World + components map[ecs.EID]*FileHandleComponent +} + +// Init initializes the component map with the given world +func (cm *FileHandleMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*FileHandleMap) ID() ecs.ComponentID { + return FileHandleCID +} + +// NewMap returns a new component map the component type +func (*FileHandleMap) NewMap() ecs.ComponentMap { + return NewFileHandleMap() +} + +// Add a new FileHandleComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *FileHandleMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + cm.components[id] = &FileHandleComponent{Data: nil} + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// AddFileHandle adds a new FileHandleComponent for the given entity id and returns it. +// If the entity already has a FileHandleComponent, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *FileHandleComponent instead of an ecs.Component +func (cm *FileHandleMap) AddFileHandle(id ecs.EID) *FileHandleComponent { + return cm.Add(id).(*FileHandleComponent) +} + +// Get returns the component associated with the given entity id +func (cm *FileHandleMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetFileHandle returns the FileHandleComponent component associated with the given entity id +func (cm *FileHandleMap) GetFileHandle(id ecs.EID) (*FileHandleComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *FileHandleMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2components/file_path.go b/d2core/d2components/file_path.go new file mode 100644 index 00000000..1bf86c45 --- /dev/null +++ b/d2core/d2components/file_path.go @@ -0,0 +1,99 @@ +package d2components + +import ( + "github.com/gravestench/ecs" +) + +// static check that FilePathComponent implements Component +var _ ecs.Component = &FilePathComponent{} + +// static check that FilePathMap implements ComponentMap +var _ ecs.ComponentMap = &FilePathMap{} + +// FilePathComponent is a component that contains a file Path string +type FilePathComponent struct { + Path string +} + +// ID returns a unique identifier for the component type +func (*FilePathComponent) ID() ecs.ComponentID { + return FilePathCID +} + +// NewMap returns a new component map the component type +func (*FilePathComponent) NewMap() ecs.ComponentMap { + return NewFilePathMap() +} + +// FilePath is a convenient reference to be used as a component identifier +var FilePath = (*FilePathComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewFilePathMap creates a new map of entity ID's to FilePath +func NewFilePathMap() *FilePathMap { + cm := &FilePathMap{ + components: make(map[ecs.EID]*FilePathComponent), + } + + return cm +} + +// FilePathMap is a map of entity ID's to FilePath +type FilePathMap struct { + world *ecs.World + components map[ecs.EID]*FilePathComponent +} + +// Init initializes the component map with the given world +func (cm *FilePathMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*FilePathMap) ID() ecs.ComponentID { + return FilePathCID +} + +// NewMap returns a new component map the component type +func (*FilePathMap) NewMap() ecs.ComponentMap { + return NewFilePathMap() +} + +// Add a new FilePathComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *FilePathMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + cm.components[id] = &FilePathComponent{Path: ""} + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// AddFilePath adds a new FilePathComponent for the given entity id and returns it. +// If the entity already has a FilePathComponent, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *FilePathComponent instead of an ecs.Component +func (cm *FilePathMap) AddFilePath(id ecs.EID) *FilePathComponent { + return cm.Add(id).(*FilePathComponent) +} + +// Get returns the component associated with the given entity id +func (cm *FilePathMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetFilePath returns the FilePathComponent associated with the given entity id +func (cm *FilePathMap) GetFilePath(id ecs.EID) (*FilePathComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *FilePathMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2components/file_source.go b/d2core/d2components/file_source.go new file mode 100644 index 00000000..6d98bb27 --- /dev/null +++ b/d2core/d2components/file_source.go @@ -0,0 +1,117 @@ +package d2components + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" +) + +// static check that FileSourceComponent implements Component +var _ ecs.Component = &FileSourceComponent{} + +// static check that FileSourceMap implements ComponentMap +var _ ecs.ComponentMap = &FileSourceMap{} + +// AbstractSource is the abstract representation of what a file source is +type AbstractSource interface { + Open(path *FilePathComponent) (d2interface.DataStream, error) +} + +// FileSourceComponent is a component that contains a FileSourceComponent instance +type FileSourceComponent struct { + AbstractSource +} + +// ID returns a unique identifier for the component type +func (*FileSourceComponent) ID() ecs.ComponentID { + return FileSourceCID +} + +// NewMap returns a new component map the component type +func (*FileSourceComponent) NewMap() ecs.ComponentMap { + return NewFileSourceMap() +} + +// FileSource is a convenient reference to be used as a component identifier +var FileSource = (*FileSourceComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewFileSourceMap creates a new map of entity ID's to FileSourceComponent components +func NewFileSourceMap() *FileSourceMap { + cm := &FileSourceMap{ + components: make(map[ecs.EID]*FileSourceComponent), + } + + return cm +} + +// FileSourceMap is a map of entity ID's to FileSourceComponent type components +type FileSourceMap struct { + world *ecs.World + components map[ecs.EID]*FileSourceComponent +} + +// Init initializes the component map with the given world +func (cm *FileSourceMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*FileSourceMap) ID() ecs.ComponentID { + return FileSourceCID +} + +// NewMap returns a new component map the component type +func (*FileSourceMap) NewMap() ecs.ComponentMap { + return NewFileSourceMap() +} + +// Add a new FileSourceComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *FileSourceMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + cm.components[id] = &FileSourceComponent{} + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// AddFileSource adds a new FileSourceComponent for the given entity id and returns it. +// If the entity already has a file type component, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *FileSourceComponent instead of an ecs.Component +func (cm *FileSourceMap) AddFileSource(id ecs.EID) *FileSourceComponent { + return cm.Add(id).(*FileSourceComponent) +} + +// Get returns the component associated with the given entity id +func (cm *FileSourceMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetFileSource returns the FileSourceComponent type component associated with the given entity id +func (cm *FileSourceMap) GetFileSource(id ecs.EID) (*FileSourceComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetFileSources returns all FileSourceComponent components +func (cm *FileSourceMap) GetFileSources() []*FileSourceComponent { + result := make([]*FileSourceComponent, 0) + + for _, src := range cm.components { + result = append(result, src) + } + + return result +} + +// Remove a component for the given entity id, return the component. +func (cm *FileSourceMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2components/file_type.go b/d2core/d2components/file_type.go new file mode 100644 index 00000000..0792ee36 --- /dev/null +++ b/d2core/d2components/file_type.go @@ -0,0 +1,101 @@ +package d2components + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" +) + +// static check that FileTypeComponent implements Component +var _ ecs.Component = &FileTypeComponent{} + +// static check that FileTypeMap implements ComponentMap +var _ ecs.ComponentMap = &FileTypeMap{} + +// FileTypeComponent is a component that contains a file Type +type FileTypeComponent struct { + Type d2enum.FileType +} + +// ID returns a unique identifier for the component type +func (*FileTypeComponent) ID() ecs.ComponentID { + return FileTypeCID +} + +// NewMap returns a new component map the component type +func (*FileTypeComponent) NewMap() ecs.ComponentMap { + return NewFileTypeMap() +} + +// FileType is a convenient reference to be used as a component identifier +var FileType = (*FileTypeComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewFileTypeMap creates a new map of entity ID's to FileType +func NewFileTypeMap() *FileTypeMap { + cm := &FileTypeMap{ + components: make(map[ecs.EID]*FileTypeComponent), + } + + return cm +} + +// FileTypeMap is a map of entity ID's to FileType +type FileTypeMap struct { + world *ecs.World + components map[ecs.EID]*FileTypeComponent +} + +// Init initializes the component map with the given world +func (cm *FileTypeMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*FileTypeMap) ID() ecs.ComponentID { + return FileTypeCID +} + +// NewMap returns a new component map the component type +func (*FileTypeMap) NewMap() ecs.ComponentMap { + return NewFileTypeMap() +} + +// Add a new FileTypeComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *FileTypeMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + cm.components[id] = &FileTypeComponent{Type: d2enum.FileTypeUnknown} + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// AddFileType adds a new FileTypeComponent for the given entity id and returns it. +// If the entity already has a file type component, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *FileTypeComponent instead of an ecs.Component +func (cm *FileTypeMap) AddFileType(id ecs.EID) *FileTypeComponent { + return cm.Add(id).(*FileTypeComponent) +} + +// Get returns the component associated with the given entity id +func (cm *FileTypeMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetFileType returns the FileTypeComponent associated with the given entity id +func (cm *FileTypeMap) GetFileType(id ecs.EID) (*FileTypeComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *FileTypeMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2components/game_config.go b/d2core/d2components/game_config.go new file mode 100644 index 00000000..554ad4c7 --- /dev/null +++ b/d2core/d2components/game_config.go @@ -0,0 +1,172 @@ +package d2components + +import ( + "os/user" + "path" + "runtime" + + "github.com/gravestench/ecs" +) + +// static check that GameConfigComponent implements Component +var _ ecs.Component = &GameConfigComponent{} + +// static check that GameConfigMap implements ComponentMap +var _ ecs.ComponentMap = &GameConfigMap{} + +type GameConfigComponent struct { + MpqLoadOrder []string + Language string + MpqPath string + TicksPerSecond int + FpsCap int + SfxVolume float64 + BgmVolume float64 + FullScreen bool + RunInBackground bool + VsyncEnabled bool + Backend string +} + +// ID returns a unique identifier for the component type +func (*GameConfigComponent) ID() ecs.ComponentID { + return GameConfigCID +} + +// NewMap returns a new component map the component type +func (*GameConfigComponent) NewMap() ecs.ComponentMap { + return NewGameConfigMap() +} + +// GameConfig is a convenient reference to be used as a component identifier +var GameConfig = (*GameConfigComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewGameConfigMap creates a new map of entity ID's to GameConfigComponent components +func NewGameConfigMap() *GameConfigMap { + cm := &GameConfigMap{ + components: make(map[ecs.EID]*GameConfigComponent), + } + + return cm +} + +// GameConfigMap is a map of entity ID's to GameConfigComponent components +type GameConfigMap struct { + world *ecs.World + components map[ecs.EID]*GameConfigComponent +} + +// Init initializes the component map with the given world +func (cm *GameConfigMap) Init(world *ecs.World) { + cm.world = world +} + +// Add a new GameConfigComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *GameConfigMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + cm.components[id] = defaultConfig() + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// ID returns a unique identifier for the component type +func (*GameConfigMap) ID() ecs.ComponentID { + return GameConfigCID +} + +// NewMap returns a new component map the component type +func (*GameConfigMap) NewMap() ecs.ComponentMap { + return NewGameConfigMap() +} + +// AddGameConfig adds a new GameConfigComponent for the given entity id and returns it. +// If the entity already has a GameConfigComponent component, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *GameConfigComponent instead of an ecs.Component +func (cm *GameConfigMap) AddGameConfig(id ecs.EID) *GameConfigComponent { + return cm.Add(id).(*GameConfigComponent) +} + +// Get returns the component associated with the given entity id +func (cm *GameConfigMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetGameConfig returns the GameConfigComponent component associated with the given entity id +func (cm *GameConfigMap) GetGameConfig(id ecs.EID) (*GameConfigComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *GameConfigMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} + +func defaultConfig() *GameConfigComponent { + const ( + defaultSfxVolume = 1.0 + defaultBgmVolume = 0.3 + ) + + config := &GameConfigComponent{ + Language: "ENG", + FullScreen: false, + TicksPerSecond: -1, + RunInBackground: true, + VsyncEnabled: true, + SfxVolume: defaultSfxVolume, + BgmVolume: defaultBgmVolume, + MpqPath: "C:/Program Files (x86)/Diablo II", + Backend: "Ebiten", + MpqLoadOrder: []string{ + "Patch_D2.mpq", + "d2exp.mpq", + "d2xmusic.mpq", + "d2xtalk.mpq", + "d2xvideo.mpq", + "d2data.mpq", + "d2char.mpq", + "d2music.mpq", + "d2sfx.mpq", + "d2video.mpq", + "d2speech.mpq", + }, + } + + switch runtime.GOOS { + case "windows": + if runtime.GOARCH == "386" { + config.MpqPath = "C:/Program Files/Diablo II" + } + case "darwin": + config.MpqPath = "/Applications/Diablo II/" + config.MpqLoadOrder = []string{ + "Diablo II Patch", + "Diablo II Expansion Data", + "Diablo II Expansion Movies", + "Diablo II Expansion Music", + "Diablo II Expansion Speech", + "Diablo II Game Data", + "Diablo II Graphics", + "Diablo II Movies", + "Diablo II Music", + "Diablo II Sounds", + "Diablo II Speech", + } + case "linux": + if usr, err := user.Current(); err == nil { + config.MpqPath = path.Join(usr.HomeDir, ".wine/drive_c/Program Files (x86)/Diablo II") + } + } + + return config +} diff --git a/d2core/d2components/position.go b/d2core/d2components/position.go new file mode 100644 index 00000000..733d4a81 --- /dev/null +++ b/d2core/d2components/position.go @@ -0,0 +1,102 @@ +package d2components + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector" +) + +// static check that PositionComponent implements Component +var _ ecs.Component = &PositionComponent{} + +// static check that PositionMap implements ComponentMap +var _ ecs.ComponentMap = &PositionMap{} + +// PositionComponent stores an x,y position +type PositionComponent struct { + *d2vector.Position +} + +// ID returns a unique identifier for the component type +func (*PositionComponent) ID() ecs.ComponentID { + return PositionCID +} + +// NewMap returns a new component map the component type +func (*PositionComponent) NewMap() ecs.ComponentMap { + return NewPositionMap() +} + +// Position is a convenient reference to be used as a component identifier +var Position = (*PositionComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewPositionMap creates a new map of entity ID's to position components +func NewPositionMap() *PositionMap { + cm := &PositionMap{ + components: make(map[ecs.EID]*PositionComponent), + } + + return cm +} + +// PositionMap is a map of entity ID's to position components +type PositionMap struct { + world *ecs.World + components map[ecs.EID]*PositionComponent +} + +// Init initializes the component map with the given world +func (cm *PositionMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*PositionMap) ID() ecs.ComponentID { + return PositionCID +} + +// NewMap returns a new component map the component type +func (*PositionMap) NewMap() ecs.ComponentMap { + return NewPositionMap() +} + +// Add a new PositionComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *PositionMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + position := d2vector.NewPosition(0, 0) + cm.components[id] = &PositionComponent{Position: &position} + + cm.world.UpdateEntity(id) + + return cm.components[id] +} + +// AddPosition adds a new PositionComponent for the given entity id and returns it. +// If the entity already has a position component, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *PositionComponent instead of an ecs.Component +func (cm *PositionMap) AddPosition(id ecs.EID) *PositionComponent { + return cm.Add(id).(*PositionComponent) +} + +// Get returns the component associated with the given entity id +func (cm *PositionMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetPosition returns the position component associated with the given entity id +func (cm *PositionMap) GetPosition(id ecs.EID) (*PositionComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *PositionMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2components/velocity.go b/d2core/d2components/velocity.go new file mode 100644 index 00000000..a604e44c --- /dev/null +++ b/d2core/d2components/velocity.go @@ -0,0 +1,102 @@ +package d2components + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector" +) + +// static check that VelocityComponent implements Component +var _ ecs.Component = &VelocityComponent{} + +// static check that VelocityMap implements ComponentMap +var _ ecs.ComponentMap = &VelocityMap{} + +// VelocityComponent stores the velocity as a vec2 +type VelocityComponent struct { + *d2vector.Vector +} + +// ID returns a unique identifier for the component type +func (*VelocityComponent) ID() ecs.ComponentID { + return VelocityCID +} + +// NewMap returns a new component map the component type +func (*VelocityComponent) NewMap() ecs.ComponentMap { + return NewVelocityMap() +} + +// Velocity is a convenient reference to be used as a component identifier +var Velocity = (*VelocityComponent)(nil) // nolint:gochecknoglobals // global by design + +// NewVelocityMap creates a new map of entity ID's to velocity components +func NewVelocityMap() *VelocityMap { + return &VelocityMap{ + components: make(map[ecs.EID]*VelocityComponent), + } +} + +// VelocityMap is a map of entity ID's to velocity components +type VelocityMap struct { + world *ecs.World + components map[ecs.EID]*VelocityComponent +} + +// Init initializes the component map with the given world +func (cm *VelocityMap) Init(world *ecs.World) { + cm.world = world +} + +// ID returns a unique identifier for the component type +func (*VelocityMap) ID() ecs.ComponentID { + return VelocityCID +} + +// NewMap returns a new component map the component type +func (*VelocityMap) NewMap() ecs.ComponentMap { + return NewVelocityMap() +} + +// Add a new VelocityComponent for the given entity id, return that component. +// If the entity already has a component, just return that one. +func (cm *VelocityMap) Add(id ecs.EID) ecs.Component { + if com, has := cm.components[id]; has { + return com + } + + v := d2vector.NewVector(0, 0) + com := &VelocityComponent{Vector: v} + cm.components[id] = com + + cm.world.UpdateEntity(id) + + return com +} + +// AddVelocity adds a new VelocityComponent for the given entity id and returns it. +// If the entity already has a component, just return that one. +// this is a convenience method for the generic Add method, as it returns a +// *VelocityComponent instead of an ecs.Component +func (cm *VelocityMap) AddVelocity(id ecs.EID) *VelocityComponent { + return cm.Add(id).(*VelocityComponent) +} + +// Get returns the component associated with the given entity id +func (cm *VelocityMap) Get(id ecs.EID) (ecs.Component, bool) { + entry, found := cm.components[id] + return entry, found +} + +// GetVelocity returns the velocity component associated with the given entity id. +// This is used to return a *VelocityComponent, as opposed to an ecs.Component +func (cm *VelocityMap) GetVelocity(id ecs.EID) (*VelocityComponent, bool) { + entry, found := cm.components[id] + return entry, found +} + +// Remove a component for the given entity id, return the component. +func (cm *VelocityMap) Remove(id ecs.EID) { + delete(cm.components, id) + cm.world.UpdateEntity(id) +} diff --git a/d2core/d2records/component_codes_record.go b/d2core/d2records/component_codes_record.go index d5744202..69e85737 100644 --- a/d2core/d2records/component_codes_record.go +++ b/d2core/d2records/component_codes_record.go @@ -1,6 +1,6 @@ package d2records -// ComponentCodes is a lookup table for DCC Animation Component Subtype, +// ComponentCodes is a lookup table for DCC Animation ComponentID Subtype, // it links hardcoded data with the txt files type ComponentCodes map[string]*ComponentCodeRecord diff --git a/d2core/d2systems/asset_loader.go b/d2core/d2systems/asset_loader.go new file mode 100644 index 00000000..3cc6f6ce --- /dev/null +++ b/d2core/d2systems/asset_loader.go @@ -0,0 +1,204 @@ +package d2systems + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func NewAssetLoader() *AssetLoaderSystem { + filesToLoad := ecs.NewFilter() + + // subscribe to entities with a file path+type+handle, ready to be loaded + filesToLoad.Require(d2components.FilePath, d2components.FileType, d2components.FileHandle) + + // exclude entities that have already been loaded + filesToLoad.Forbid(d2components.GameConfig). + //Forbid(d2components.AssetStringTableCID). + //Forbid(d2components.AssetDataDictionaryCID). + //Forbid(d2components.AssetPaletteCID). + //Forbid(d2components.AssetPaletteTransformCID). + //Forbid(d2components.AssetCofCID). + //Forbid(d2components.AssetDc6CID). + //Forbid(d2components.AssetDccCID). + //Forbid(d2components.AssetDs1CID). + //Forbid(d2components.AssetDt1CID). + //Forbid(d2components.AssetWavCID). + //Forbid(d2components.AssetD2CID). + Build() + + // subscribe to entities that have a source type and a source component + fileSources := ecs.NewFilter(). + Require(d2components.FileSource). + Build() + + return &AssetLoaderSystem{ + SubscriberSystem: ecs.NewSubscriberSystem(filesToLoad.Build(), fileSources), + } +} + +var _ ecs.System = &AssetLoaderSystem{} + +type AssetLoaderSystem struct { + *ecs.SubscriberSystem + fileSub *ecs.Subscription + sourceSub *ecs.Subscription + filePaths *d2components.FilePathMap + fileTypes *d2components.FileTypeMap + fileHandles *d2components.FileHandleMap + fileSources *d2components.FileSourceMap +} + +// Init initializes the system with the given world +func (m *AssetLoaderSystem) Init(world *ecs.World) { + m.World = world + + if world == nil { + m.SetActive(false) + return + } + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + m.fileSub = m.Subscriptions[0] + m.sourceSub = m.Subscriptions[1] + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.filePaths = m.InjectMap(d2components.FilePath).(*d2components.FilePathMap) + m.fileTypes = m.InjectMap(d2components.FileType).(*d2components.FileTypeMap) + m.fileHandles = m.InjectMap(d2components.FileHandle).(*d2components.FileHandleMap) + m.fileSources = m.InjectMap(d2components.FileSource).(*d2components.FileSourceMap) +} + +// Process processes all of the Entities +func (m *AssetLoaderSystem) Process() { + for _, eid := range m.fileSub.GetEntities() { + m.ProcessEntity(eid) + } +} + +// ProcessEntity updates an individual entity in the system +func (m *AssetLoaderSystem) ProcessEntity(id ecs.EID) { + ft, found := m.fileTypes.GetFileType(id) + if !found { + return + } + + fh, found := m.fileHandles.GetFileHandle(id) + if !found { + return + } + + data, buf := make([]byte, 0), make([]byte, 16) + for { + numRead, err := fh.Data.Read(buf) + data = append(data, buf[:numRead]...) + if numRead < 1 || err != nil { + break + } + } + + var err error + + switch ft.Type { + case d2enum.FileTypeJSON: + err = m.loadFileTypeJSON(id, data) + //case d2enum.FileTypeStringTable: + // err = m.loadFileTypeStringTable(id, data) + //case d2enum.FileTypeDataDictionary: + // err = m.loadFileTypeDataDictionary(id, data) + //case d2enum.FileTypePalette: + // err = m.loadFileTypePalette(id, data) + //case d2enum.FileTypePaletteTransform: + // err = m.loadFileTypePaletteTransform(id, data) + //case d2enum.FileTypeCOF: + // err = m.loadFileTypeCOF(id, data) + //case d2enum.FileTypeDC6: + // err = m.loadFileTypeDC6(id, data) + //case d2enum.FileTypeDCC: + // err = m.loadFileTypeDCC(id, data) + //case d2enum.FileTypeDS1: + // err = m.loadFileTypeDS1(id, data) + //case d2enum.FileTypeDT1: + // err = m.loadFileTypeDT1(id, data) + //case d2enum.FileTypeWAV: + // err = m.loadFileTypeWAV(id, data) + //case d2enum.FileTypeD2: + // err = m.loadFileTypeD2(id, data) + } + + if err != nil { + ft.Type = d2enum.FileTypeUnknown + } +} + +func (m *AssetLoaderSystem) loadFileTypeJSON(id ecs.EID, data []byte) error { + _, found := m.filePaths.GetFilePath(id) + if !found { + return errors.New("file path component for entity not found") + } + + var result map[string]interface{} + + // Unmarshal or Decode the JSON to the interface. + if err := json.Unmarshal(data, &result); err != nil { + return err + } + + mpq := result["MpqLoadOrder"].([]interface{}) + fmt.Println("Address :", mpq) + + return nil +} + +//func (m *AssetLoaderSystem) loadFileTypeStringTable(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeDataDictionary(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypePalette(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypePaletteTransform(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeCOF(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeDC6(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeDCC(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeDS1(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeDT1(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeWAV(id ecs.EID, data []byte) error { +// +//} +// +//func (m *AssetLoaderSystem) loadFileTypeD2(id ecs.EID, data []byte) error { +// +//} diff --git a/d2core/d2systems/file_handle_resolver.go b/d2core/d2systems/file_handle_resolver.go new file mode 100644 index 00000000..3b221e5b --- /dev/null +++ b/d2core/d2systems/file_handle_resolver.go @@ -0,0 +1,78 @@ +package d2systems + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func NewFileHandleResolver() *FileHandleResolutionSystem { + // this filter is for entities that have a file path and file type but no file handle. + filesToSource := ecs.NewFilter(). + Require(d2components.FilePath, d2components.FileType). + Forbid(d2components.FileHandle, d2components.FileSource). + Build() + + return &FileHandleResolutionSystem{ + SubscriberSystem: ecs.NewSubscriberSystem(filesToSource), + } +} + +type FileHandleResolutionSystem struct { + *ecs.SubscriberSystem + fileSub *ecs.Subscription + filePaths *d2components.FilePathMap + fileTypes *d2components.FileTypeMap + fileSources *d2components.FileSourceMap + fileHandles *d2components.FileHandleMap +} + +// Init initializes the system with the given world +func (m *FileHandleResolutionSystem) Init(world *ecs.World) { + m.World = world + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + if world == nil { + m.SetActive(false) + return + } + + m.fileSub = m.Subscriptions[0] + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.filePaths = m.InjectMap(d2components.FilePath).(*d2components.FilePathMap) + m.fileTypes = m.InjectMap(d2components.FileType).(*d2components.FileTypeMap) + m.fileHandles = m.InjectMap(d2components.FileHandle).(*d2components.FileHandleMap) + m.fileSources = m.InjectMap(d2components.FileSource).(*d2components.FileSourceMap) +} + +// Process processes all of the Entities +func (m *FileHandleResolutionSystem) Process() { + for _, EID := range m.fileSub.GetEntities() { + m.ProcessEntity(EID) + } +} + +// ProcessEntity updates an individual entity in the system +func (m *FileHandleResolutionSystem) ProcessEntity(id ecs.EID) { + fp, found := m.filePaths.GetFilePath(id) + if !found { + return + } + + for _, source := range m.fileSources.GetFileSources() { + data, err := source.Open(fp) + if err != nil { + continue + } + + dataComponent := m.fileHandles.AddFileHandle(id) + dataComponent.Data = data + + break + } +} diff --git a/d2core/d2systems/file_handle_resolver_test.go b/d2core/d2systems/file_handle_resolver_test.go new file mode 100644 index 00000000..bc4da3eb --- /dev/null +++ b/d2core/d2systems/file_handle_resolver_test.go @@ -0,0 +1,87 @@ +package d2systems + +import ( + "strings" + "testing" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func Test_FileHandleResolver_Process(t *testing.T) { + cfg := ecs.NewWorldConfig() + + fileTypeResolver := NewFileTypeResolver() + fileHandleResolver := NewFileHandleResolver() + fileSourceResolver := NewFileSourceResolver() + + cfg.With(fileTypeResolver). + With(fileSourceResolver). + With(fileHandleResolver) + + world := ecs.NewWorld(cfg) + + filepathMap, err := world.GetMap(d2components.FilePath) + if err != nil { + t.Error("file path component map not found") + } + + filePaths := filepathMap.(*d2components.FilePathMap) + + sourceEntity := world.NewEntity() + sourceFp := filePaths.AddFilePath(sourceEntity) + sourceFp.Path = "./testdata/" + + //_ = world.Update(0) + + fileEntity := world.NewEntity() + fileFp := filePaths.AddFilePath(fileEntity) + fileFp.Path = "testfile_a.txt" + + _ = world.Update(0) + + ft, found := fileTypeResolver.fileTypes.GetFileType(sourceEntity) + if !found { + t.Error("file source type not created for entity") + return + } + + if ft.Type != d2enum.FileTypeDirectory { + t.Error("expected file system source type for entity") + return + } + + handleMap, err := world.GetMap(d2components.FileHandle) + if err != nil { + t.Error("file handle component map is nil") + return + } + + fileHandles := handleMap.(*d2components.FileHandleMap) + + handle, found := fileHandles.GetFileHandle(fileEntity) + if !found { + t.Error("file handle for entity was not found") + return + } + + data, buf := make([]byte, 0), make([]byte, 16) + + for { + numRead, err := handle.Data.Read(buf) + + data = append(data, buf[:numRead]...) + + if err != nil || numRead == 0 { + break + } + } + + result := strings.Trim(string(data), "\r\n") + + if result != "test a" { + t.Error("unexpected data read from `./testdata/testfile_a.txt`") + } +} diff --git a/d2core/d2systems/file_source_resolver.go b/d2core/d2systems/file_source_resolver.go new file mode 100644 index 00000000..fb7ede2c --- /dev/null +++ b/d2core/d2systems/file_source_resolver.go @@ -0,0 +1,150 @@ +package d2systems + +import ( + "os" + "path/filepath" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2fileformats/d2mpq" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func NewFileSourceResolver() *FileSourceResolver { + // subscribe to entities with a file type and file path, but no file source type + filesToCheck := ecs.NewFilter(). + Require(d2components.FilePath). + Require(d2components.FileType). + Forbid(d2components.FileSource). + Build() + + return &FileSourceResolver{ + SubscriberSystem: ecs.NewSubscriberSystem(filesToCheck), + } +} + +type FileSourceResolver struct { + *ecs.SubscriberSystem + fileSub *ecs.Subscription + filePaths *d2components.FilePathMap + fileTypes *d2components.FileTypeMap + fileSources *d2components.FileSourceMap +} + +// Init initializes the system with the given world +func (m *FileSourceResolver) Init(world *ecs.World) { + m.World = world + + if world == nil { + m.SetActive(false) + return + } + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + m.fileSub = m.Subscriptions[0] + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.filePaths = m.InjectMap(d2components.FilePath).(*d2components.FilePathMap) + m.fileTypes = m.InjectMap(d2components.FileType).(*d2components.FileTypeMap) + m.fileSources = m.InjectMap(d2components.FileSource).(*d2components.FileSourceMap) +} + +// Process processes all of the Entities +func (m *FileSourceResolver) Process() { + for subIdx := range m.Subscriptions { + for _, sourceEntityID := range m.Subscriptions[subIdx].GetEntities() { + m.ProcessEntity(sourceEntityID) + } + } +} + +// ProcessEntity updates an individual entity in the system +func (m *FileSourceResolver) ProcessEntity(id ecs.EID) { + fp, found := m.filePaths.GetFilePath(id) + if !found { + return + } + + fst, found := m.fileTypes.GetFileType(id) + if !found { + return + } + + switch fst.Type { + case d2enum.FileTypeUnknown: + return + case d2enum.FileTypeMPQ: + source := m.fileSources.AddFileSource(id) + instance, err := m.makeMpqSource(fp.Path) + + if err != nil { + fst.Type = d2enum.FileTypeUnknown + break + } + + source.AbstractSource = instance + case d2enum.FileTypeDirectory: + source := m.fileSources.AddFileSource(id) + instance, err := m.makeFileSystemSource(fp.Path) + + if err != nil { + fst.Type = d2enum.FileTypeUnknown + break + } + + source.AbstractSource = instance + default: + fst.Type = d2enum.FileTypeUnknown + } +} + +// filesystem source +func (m *FileSourceResolver) makeFileSystemSource(path string) (d2components.AbstractSource, error) { + return &fsSource{rootDir: path}, nil +} + +type fsSource struct { + rootDir string +} + +func (s *fsSource) Open(path *d2components.FilePathComponent) (d2interface.DataStream, error) { + fileData, err := os.Open(s.fullPath(path.Path)) + if err != nil { + return nil, err + } + + return fileData, nil +} + +func (s *fsSource) fullPath(path string) string { + return filepath.Clean(filepath.Join(s.rootDir, path)) +} + +// mpq source +func (m *FileSourceResolver) makeMpqSource(path string) (d2components.AbstractSource, error) { + mpq, err := d2mpq.Load(path) + if err != nil { + return nil, err + } + + return &mpqSource{mpq: mpq}, nil +} + +type mpqSource struct { + mpq d2interface.Archive +} + +func (s *mpqSource) Open(path *d2components.FilePathComponent) (d2interface.DataStream, error) { + fileData, err := s.mpq.ReadFileStream(path.Path) + if err != nil { + return nil, err + } + + return fileData, nil +} diff --git a/d2core/d2systems/file_source_resolver_test.go b/d2core/d2systems/file_source_resolver_test.go new file mode 100644 index 00000000..6b24e669 --- /dev/null +++ b/d2core/d2systems/file_source_resolver_test.go @@ -0,0 +1,44 @@ +package d2systems + +import ( + "testing" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func Test_FileSourceResolution(t *testing.T) { + cfg := ecs.NewWorldConfig() + + srcResolver := NewFileSourceResolver() + fileTypeResolver := NewFileTypeResolver() + + cfg.With(fileTypeResolver). + With(srcResolver) + + world := ecs.NewWorld(cfg) + + filepathMap, err := world.GetMap(d2components.FilePath) + if err != nil { + t.Error("file path component map not found") + } + + filePaths := filepathMap.(*d2components.FilePathMap) + + sourceEntity := world.NewEntity() + sourceFp := filePaths.AddFilePath(sourceEntity) + sourceFp.Path = "./testdata/" + + _ = world.Update(0) + + ft, found := fileTypeResolver.fileTypes.GetFileType(sourceEntity) + if !found { + t.Error("file source type not created for entity") + } + + if ft.Type != d2enum.FileTypeDirectory { + t.Error("expected file system source type for entity") + } +} diff --git a/d2core/d2systems/file_type_resolver.go b/d2core/d2systems/file_type_resolver.go new file mode 100644 index 00000000..f4a2765c --- /dev/null +++ b/d2core/d2systems/file_type_resolver.go @@ -0,0 +1,128 @@ +package d2systems + +import ( + "os" + "path/filepath" + "strings" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2fileformats/d2mpq" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +// NewFileTypeResolver creates a new file type resolution system. +func NewFileTypeResolver() *FileTypeResolutionSystem { + cfg := ecs.NewFilter() + + cfg.Require(d2components.FilePath) + + filter := cfg.Build() + + return &FileTypeResolutionSystem{ + SubscriberSystem: ecs.NewSubscriberSystem(filter), + } +} + +// static check that FileTypeResolutionSystem implements the System interface +var _ ecs.System = &FileTypeResolutionSystem{} + +// FileTypeResolutionSystem is responsible for determining file types from file file paths. +// This system will subscribe to entities that have a file path component, but do not +// have a file type component. It will use the file path component to determine the file type, +// and it will then create the file type component for the entity, thus removing the entity +// from its subscription. +type FileTypeResolutionSystem struct { + *ecs.SubscriberSystem + filePaths *d2components.FilePathMap + fileTypes *d2components.FileTypeMap +} + +// Init initializes the system with the given world +func (m *FileTypeResolutionSystem) Init(world *ecs.World) { + m.World = world + + if world == nil { + m.SetActive(false) + return + } + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.filePaths = m.InjectMap(d2components.FilePath).(*d2components.FilePathMap) + m.fileTypes = m.InjectMap(d2components.FileType).(*d2components.FileTypeMap) +} + +// Process processes all of the Entities +func (m *FileTypeResolutionSystem) Process() { + for subIdx := range m.Subscriptions { + entities := m.Subscriptions[subIdx].GetEntities() + for entIdx := range entities { + m.ProcessEntity(entities[entIdx]) + } + } +} + +// ProcessEntity updates an individual entity in the system +func (m *FileTypeResolutionSystem) ProcessEntity(id ecs.EID) { + fp, found := m.filePaths.GetFilePath(id) + if !found { + return + } + + ft := m.fileTypes.AddFileType(id) + + if _, err := d2mpq.Load(fp.Path); err == nil { + ft.Type = d2enum.FileTypeMPQ + return + } + + ext := strings.ToLower(filepath.Ext(fp.Path)) + + switch ext { + case ".mpq": + ft.Type = d2enum.FileTypeMPQ + case ".d2": + ft.Type = d2enum.FileTypeD2 + case ".dcc": + ft.Type = d2enum.FileTypeDCC + case ".dc6": + ft.Type = d2enum.FileTypeDC6 + case ".wav": + ft.Type = d2enum.FileTypeWAV + case ".ds1": + ft.Type = d2enum.FileTypeDS1 + case ".dt1": + ft.Type = d2enum.FileTypeDT1 + case ".pl2": + ft.Type = d2enum.FileTypePaletteTransform + case ".dat": + ft.Type = d2enum.FileTypePalette + case ".tbl": + ft.Type = d2enum.FileTypeStringTable + case ".txt": + ft.Type = d2enum.FileTypeDataDictionary + case ".cof": + ft.Type = d2enum.FileTypeCOF + case ".json": + ft.Type = d2enum.FileTypeJSON + default: + cleanPath := filepath.Clean(fp.Path) + + info, err := os.Lstat(cleanPath) + if err != nil { + ft.Type = d2enum.FileTypeUnknown + return + } + + if info.Mode().IsDir() { + ft.Type = d2enum.FileTypeDirectory + return + } + } +} diff --git a/d2core/d2systems/file_type_resolver_test.go b/d2core/d2systems/file_type_resolver_test.go new file mode 100644 index 00000000..c1084baf --- /dev/null +++ b/d2core/d2systems/file_type_resolver_test.go @@ -0,0 +1,66 @@ +package d2systems + +import ( + "testing" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" +) + +func TestNewFileTypeResolver_KnownType(t *testing.T) { + cfg := ecs.NewWorldConfig() + + resolver := NewFileTypeResolver() + + cfg.With(resolver) + + world := ecs.NewWorld(cfg) + + e := world.NewEntity() + + fp := resolver.filePaths.AddFilePath(e) + fp.Path = "/some/path/to/a/file.dcc" + + if len(resolver.Subscriptions[0].GetEntities()) != 1 { + t.Error("entity with file path not added to file type resolver subscription") + } + + _ = world.Update(0) + + if len(resolver.Subscriptions[0].GetEntities()) != 0 { + t.Error("entity with existing file type not removed from file type resolver subscription") + } + + ft, found := resolver.fileTypes.GetFileType(e) + if !found { + t.Error("file type component not added to entity with file path component") + } + + if ft.Type != d2enum.FileTypeDCC { + t.Error("unexpected file type") + } +} + +func TestNewFileTypeResolver_UnknownType(t *testing.T) { + cfg := ecs.NewWorldConfig() + + resolver := NewFileTypeResolver() + + cfg.With(resolver) + + world := ecs.NewWorld(cfg) + + e := world.NewEntity() + + fp := resolver.filePaths.AddFilePath(e) + fp.Path = "/some/path/to/a/file.XYZ" + + _ = world.Update(0) + + ft, _ := resolver.fileTypes.GetFileType(e) + + if ft.Type != d2enum.FileTypeUnknown { + t.Error("unexpected file type") + } +} diff --git a/d2core/d2systems/game_config.go b/d2core/d2systems/game_config.go new file mode 100644 index 00000000..001308e2 --- /dev/null +++ b/d2core/d2systems/game_config.go @@ -0,0 +1,59 @@ +package d2systems + +import ( + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +// static check that the game config system implements the system interface +var _ ecs.System = &GameConfigSystem{} + +func NewGameConfigSystem() *GameConfigSystem { + gameConfigs := ecs.NewFilter(). + Require(d2components.GameConfig). + Build() + + gcs := &GameConfigSystem{ + SubscriberSystem: ecs.NewSubscriberSystem(gameConfigs), + } + + return gcs +} + +type GameConfigSystem struct { + *ecs.SubscriberSystem + configs *d2components.GameConfigMap + filePaths *d2components.FilePathMap + fileTypes *d2components.FileTypeMap + fileHandles *d2components.FileHandleMap + fileSources *d2components.FileSourceMap +} + +func (m *GameConfigSystem) Init(world *ecs.World) { + m.World = world + + if world == nil { + m.SetActive(false) + return + } + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.filePaths = world.InjectMap(d2components.FilePath).(*d2components.FilePathMap) + m.fileTypes = world.InjectMap(d2components.FileType).(*d2components.FileTypeMap) + m.fileHandles = world.InjectMap(d2components.FileHandle).(*d2components.FileHandleMap) + m.fileSources = world.InjectMap(d2components.FileSource).(*d2components.FileSourceMap) +} + +func (m *GameConfigSystem) Process() { + for subIdx := range m.Subscriptions { + for _, EID := range m.Subscriptions[subIdx].GetEntities() { + _ = EID + } + } +} diff --git a/d2core/d2systems/integration_test.go b/d2core/d2systems/integration_test.go new file mode 100644 index 00000000..ff1d711c --- /dev/null +++ b/d2core/d2systems/integration_test.go @@ -0,0 +1,57 @@ +package d2systems + +import ( + "testing" + "time" + + "github.com/gravestench/ecs" +) + +func Test_SystemIntegrationTest(t *testing.T) { + cfg := ecs.NewWorldConfig() + + scale := NewTimeScaleSystem() + movement := NewMovementSystem() + + cfg.With(scale) + cfg.With(movement) + + world := ecs.NewWorld(cfg) + + e := world.NewEntity() + pos := movement.positions.AddPosition(e) + vel := movement.velocities.AddVelocity(e) + + vel.Set(1, 2) + + // first test without time scaling active + scale.scale = 0.001 + scale.SetActive(false) + + timeDelta := time.Millisecond + + expectX, expectY := pos.X()+vel.X(), pos.Y()+vel.Y() + + for idx := 0; idx < 1000; idx++ { + _ = world.Update(timeDelta) + } + + if !pos.EqualsApprox(vel.Vector) { + fmtStr := "position component not updated, expected (%v,%v) but got (%v,%v)" + t.Errorf(fmtStr, expectX, expectY, pos.X(), pos.Y()) + } + + // now enable time scaling + scale.SetActive(true) + + expectX, expectY = pos.X()+vel.X(), pos.Y()+vel.Y() + + for idx := 0; idx < 1000000; idx++ { + _ = world.Update(timeDelta) + } + + if pos.EqualsApprox(vel.Vector.Clone().Scale(2)) { + fmtStr := "position component not updated, expected (%v,%v) but got (%v,%v)" + t.Errorf(fmtStr, expectX, expectY, pos.X(), pos.Y()) + } +} diff --git a/d2core/d2systems/movement.go b/d2core/d2systems/movement.go new file mode 100644 index 00000000..026dfafa --- /dev/null +++ b/d2core/d2systems/movement.go @@ -0,0 +1,75 @@ +package d2systems + +import ( + "time" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +// NewMovementSystem creates a movement system +func NewMovementSystem() *MovementSystem { + cfg := ecs.NewFilter().Require(d2components.Position, d2components.Velocity) + + filter := cfg.Build() + + return &MovementSystem{ + SubscriberSystem: ecs.NewSubscriberSystem(filter), + } +} + +// static check that MovementSystem implements the System interface +var _ ecs.System = &MovementSystem{} + +// MovementSystem handles entity movement based on velocity and position components +type MovementSystem struct { + *ecs.SubscriberSystem + positions *d2components.PositionMap + velocities *d2components.VelocityMap +} + +// Init initializes the system with the given world +func (m *MovementSystem) Init(world *ecs.World) { + m.World = world + + if world == nil { + m.SetActive(false) + return + } + + for subIdx := range m.Subscriptions { + m.AddSubscription(m.Subscriptions[subIdx]) + } + + // try to inject the components we require, then cast the returned + // abstract ComponentMap back to the concrete implementation + m.positions = m.InjectMap(d2components.Position).(*d2components.PositionMap) + m.velocities = m.InjectMap(d2components.Velocity).(*d2components.VelocityMap) +} + +// Process processes all of the Entities +func (m *MovementSystem) Process() { + for subIdx := range m.Subscriptions { + entities := m.Subscriptions[subIdx].GetEntities() + for entIdx := range entities { + m.ProcessEntity(entities[entIdx]) + } + } +} + +// ProcessEntity updates an individual entity in the movement system +func (m *MovementSystem) ProcessEntity(id ecs.EID) { + position, found := m.positions.GetPosition(id) + if !found { + return + } + + velocity, found := m.velocities.GetVelocity(id) + if !found { + return + } + + s := float64(m.World.TimeDelta) / float64(time.Second) + position.Vector = *position.Vector.Add(velocity.Vector.Clone().Scale(s)) +} diff --git a/d2core/d2systems/movement_test.go b/d2core/d2systems/movement_test.go new file mode 100644 index 00000000..c7d0379c --- /dev/null +++ b/d2core/d2systems/movement_test.go @@ -0,0 +1,154 @@ +package d2systems + +import ( + "fmt" + "math/rand" + "strconv" + "testing" + "time" + + "github.com/gravestench/ecs" + + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2components" +) + +func TestMovementSystem_Init(t *testing.T) { + cfg := ecs.NewWorldConfig() + + cfg.With(NewMovementSystem()) + + world := ecs.NewWorld(cfg) + + if len(world.Systems) != 1 { + t.Error("system not added to the world") + } +} + +func TestMovementSystem_Active(t *testing.T) { + movement := NewMovementSystem() + + if movement.Active() { + t.Error("system should not be active at creation") + } +} + +func TestMovementSystem_SetActive(t *testing.T) { + movement := NewMovementSystem() + + movement.SetActive(false) + + if movement.Active() { + t.Error("system should be inactive after being set inactive") + } +} + +func TestMovementSystem_EntityAdded(t *testing.T) { + cfg := ecs.NewWorldConfig() + + movement := NewMovementSystem() + + cfg.With(movement). + With(d2components.NewPositionMap()). + With(d2components.NewVelocityMap()) + + world := ecs.NewWorld(cfg) + + e := world.NewEntity() + + position := movement.positions.AddPosition(e) + velocity := movement.velocities.AddVelocity(e) + + px, py := 10., 10. + vx, vy := 1., 0. + + position.Set(px, py) + velocity.Set(vx, vy) + + if len(movement.Subscriptions[0].GetEntities()) != 1 { + t.Error("entity not added to the system") + } + + if p, found := movement.positions.GetPosition(e); !found { + t.Error("position component not found") + } else if p.X() != px || p.Y() != py { + fmtError := "position component values incorrect:\n\t expected %v, %v but got %v, %v" + t.Errorf(fmtError, px, py, p.X(), p.Y()) + } + + if v, found := movement.velocities.GetVelocity(e); !found { + t.Error("position component not found") + } else if v.X() != vx || v.Y() != vy { + fmtError := "velocity component values incorrect:\n\t expected %v, %v but got %v, %v" + t.Errorf(fmtError, px, py, v.X(), v.Y()) + } +} + +func TestMovementSystem_Update(t *testing.T) { + // world bootstrap + cfg := ecs.NewWorldConfig() + + movementSystem := NewMovementSystem() + positions := d2components.NewPositionMap() + velocities := d2components.NewVelocityMap() + + cfg.With(movementSystem).With(positions).With(velocities) + + world := ecs.NewWorld(cfg) + + // lets make an entity and add some components to it + e := world.NewEntity() + position := movementSystem.positions.AddPosition(e) + velocity := movementSystem.velocities.AddVelocity(e) + + px, py := 10., 10. + vx, vy := 1., -1. + + // mutate the components a bit + position.Set(px, py) + velocity.Set(vx, vy) + + // should apply the velocity to the position + _ = world.Update(time.Second) + + if position.X() != px+vx || position.Y() != py+vy { + fmtError := "expected position (%v, %v) but got (%v, %v)" + t.Errorf(fmtError, px+vx, py+vy, position.X(), position.Y()) + } +} + +func bench_N_entities(n int, b *testing.B) { + cfg := ecs.NewWorldConfig() + + movementSystem := NewMovementSystem() + + cfg.With(movementSystem) + + world := ecs.NewWorld(cfg) + + for idx := 0; idx < n; idx++ { + e := world.NewEntity() + p := movementSystem.positions.AddPosition(e) + v := movementSystem.velocities.AddVelocity(e) + + p.Set(0, 0) + v.Set(rand.Float64(), rand.Float64()) + } + + benchName := strconv.Itoa(n) + "_entity update" + b.Run(benchName, func(b *testing.B) { + for idx := 0; idx < b.N; idx++ { + _ = world.Update(time.Millisecond) + } + }) + + fmt.Println("done!") +} + +func BenchmarkMovementSystem_Update(b *testing.B) { + bench_N_entities(1e1, b) + bench_N_entities(1e2, b) + bench_N_entities(1e3, b) + bench_N_entities(1e4, b) + bench_N_entities(1e5, b) + bench_N_entities(1e6, b) +} diff --git a/d2core/d2systems/testdata/testfile_a.txt b/d2core/d2systems/testdata/testfile_a.txt new file mode 100644 index 00000000..27fa2363 --- /dev/null +++ b/d2core/d2systems/testdata/testfile_a.txt @@ -0,0 +1 @@ +test a diff --git a/d2core/d2systems/timescale.go b/d2core/d2systems/timescale.go new file mode 100644 index 00000000..1181bab5 --- /dev/null +++ b/d2core/d2systems/timescale.go @@ -0,0 +1,46 @@ +package d2systems + +import ( + "time" + + "github.com/gravestench/ecs" +) + +const ( + defaultScale float64 = 1 +) + +// NewTimeScaleSystem creates a timescale system +func NewTimeScaleSystem() *TimeScaleSystem { + m := &TimeScaleSystem{ + BaseSystem: &ecs.BaseSystem{}, + } + + return m +} + +// static check that TimeScaleSystem implements the System interface +var _ ecs.System = &TimeScaleSystem{} + +// TimeScaleSystem should be the first system added to the world, and whose only job is to +// apply a scalar the world's TimeDelta between frames. It's useful for slowing down or speeding +// up the game time without affecting the render rate. +type TimeScaleSystem struct { + *ecs.BaseSystem + scale float64 +} + +// Init will initialize the TimeScale system +func (t *TimeScaleSystem) Init(world *ecs.World) { + t.World = world + t.scale = defaultScale +} + +// Process scales the worlds time delta for this frame +func (t *TimeScaleSystem) Process() { + if !t.Active() { + return + } + + t.World.TimeDelta *= time.Duration(t.scale) +} diff --git a/d2core/d2systems/timescale_test.go b/d2core/d2systems/timescale_test.go new file mode 100644 index 00000000..ae87bdc0 --- /dev/null +++ b/d2core/d2systems/timescale_test.go @@ -0,0 +1,41 @@ +package d2systems + +import ( + "testing" + "time" + + "github.com/gravestench/ecs" +) + +func TestTimeScaleSystem_Init(t *testing.T) { + cfg := ecs.NewWorldConfig() + + cfg.With(NewTimeScaleSystem()) + + world := ecs.NewWorld(cfg) + + if len(world.Systems) != 1 { + t.Error("system not added to the world") + } +} + +func TestTimeScaleSystem_Process(t *testing.T) { + cfg := ecs.NewWorldConfig() + + timescaleSystem := NewTimeScaleSystem() + + cfg.With(timescaleSystem) + + timescaleSystem.scale = 0.01 + + world := ecs.NewWorld(cfg) + + actual := time.Second + expected := time.Duration(timescaleSystem.scale) * actual + + world.Update(actual) + + if world.TimeDelta != expected { + t.Error("world time delta not scaled") + } +}