Adding modding folder with a hacked build.

This commit is contained in:
ml
2026-09-26 08:53:08 +02:00
parent cdb960dc71
commit c91080aba7
755 changed files with 50424 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
package edit
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/resource"
)
// AugmentedTextBlockSetter modifies resource blocks.
type AugmentedTextBlockSetter interface {
SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte)
SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte)
DelResource(lang resource.Language, id resource.ID)
}
func augmentedTextAudioKeyFor(key resource.Key) resource.Key {
audioBase := KnownTexts().ByID(key.ID).audioBase
if audioBase == 0 {
return resource.KeyOf(0, resource.LangAny, 0)
}
soundKey := key
soundKey.ID = audioBase.Plus(key.Index)
soundKey.Index = 0
return soundKey
}
// AugmentedTextService provides read/write functionality.
type AugmentedTextService struct {
textViewer media.TextViewerService
textSetter media.TextSetterService
audioViewer media.AudioViewerService
audioSetter media.AudioSetterService
}
// NewAugmentedTextService returns a new instance based on given accessor.
func NewAugmentedTextService(
textViewer media.TextViewerService, textSetter media.TextSetterService,
audioViewer media.AudioViewerService, audioSetter media.AudioSetterService) AugmentedTextService {
return AugmentedTextService{
textViewer: textViewer,
textSetter: textSetter,
audioViewer: audioViewer,
audioSetter: audioSetter,
}
}
// WithAudio returns true if the identified resource is one with an audio component.
func (service AugmentedTextService) WithAudio(key resource.Key) bool {
return KnownTexts().ByID(key.ID).audioBase != 0
}
// Text returns the textual value of the identified text resource.
func (service AugmentedTextService) Text(key resource.Key) string {
return service.textViewer.Text(key)
}
// SetText changes the textual value of a text resource.
func (service AugmentedTextService) SetText(setter AugmentedTextBlockSetter, key resource.Key, value string) {
service.textSetter.Set(setter, key, value)
}
// RestoreTextFunc creates a snapshot of the current textual state and returns a function to restore it.
func (service AugmentedTextService) RestoreTextFunc(key resource.Key) func(setter AugmentedTextBlockSetter) {
oldText := service.textViewer.Text(key)
isModified := service.textViewer.Modified(key)
return func(setter AugmentedTextBlockSetter) {
if isModified {
service.textSetter.Set(setter, key, oldText)
} else {
service.textSetter.Remove(setter, key)
}
}
}
// Sound returns the audio value of the identified text resource.
// In case the text resource has no audio, an empty sound will be returned.
func (service AugmentedTextService) Sound(key resource.Key) audio.L8 {
if !service.WithAudio(key) {
return audio.L8{}
}
return service.audioViewer.Audio(augmentedTextAudioKeyFor(key))
}
// SetSound changes the sound of a text resource.
// Should the text resource have no audio component, this call does nothing.
func (service AugmentedTextService) SetSound(setter AugmentedTextBlockSetter, key resource.Key, sound audio.L8) { // nolint: interfacer
if service.WithAudio(key) {
service.audioSetter.Set(setter, augmentedTextAudioKeyFor(key), sound)
}
}
// RestoreSoundFunc creates a snapshot of the current sound and returns a function to restore it.
// In case the text resource has no audio, a stub method will be returned.
func (service AugmentedTextService) RestoreSoundFunc(key resource.Key) func(setter AugmentedTextBlockSetter) {
if !service.WithAudio(key) {
return func(setter AugmentedTextBlockSetter) {}
}
soundKey := augmentedTextAudioKeyFor(key)
isSoundModified := service.audioViewer.Modified(soundKey)
oldSound := service.audioViewer.Audio(soundKey)
return func(setter AugmentedTextBlockSetter) {
if isSoundModified {
service.audioSetter.Set(setter, soundKey, oldSound)
} else {
service.audioSetter.Remove(setter, soundKey)
}
}
}
// Clear sets the text to an empty string and sets an empty sound if audio is associated.
func (service AugmentedTextService) Clear(setter AugmentedTextBlockSetter, key resource.Key) {
service.textSetter.Clear(setter, key)
if service.WithAudio(key) {
service.audioSetter.Clear(setter, augmentedTextAudioKeyFor(key))
}
}
// Remove erases the text and audio from the resources.
func (service AugmentedTextService) Remove(setter AugmentedTextBlockSetter, key resource.Key) {
service.textSetter.Remove(setter, key)
if service.WithAudio(key) {
service.audioSetter.Remove(setter, augmentedTextAudioKeyFor(key))
}
}
// RestoreFunc creates a snapshot of all associated media components and returns a function to restore it.
func (service AugmentedTextService) RestoreFunc(key resource.Key) func(setter AugmentedTextBlockSetter) {
restoreText := service.RestoreTextFunc(key)
restoreSound := service.RestoreSoundFunc(key)
return func(setter AugmentedTextBlockSetter) {
restoreText(setter)
restoreSound(setter)
}
}
+113
View File
@@ -0,0 +1,113 @@
package edit
import (
"github.com/inkyblackness/hacked/ss1/content/archive"
"github.com/inkyblackness/hacked/ss1/content/archive/level"
"github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
// EditableLevels is a list of levels that can be modified.
type EditableLevels struct {
registry cmd.Registry
mod *world.Mod
list [archive.MaxLevels]*level.Level
}
// NewEditableLevels returns a new instance for given mod.
func NewEditableLevels(registry cmd.Registry, mod *world.Mod) *EditableLevels {
var levels EditableLevels
levels.registry = registry
levels.mod = mod
for i := 0; i < len(levels.list); i++ {
levels.list[i] = level.NewLevel(ids.LevelResourcesStart, i, mod)
}
return &levels
}
// InvalidateResources informs all levels of changed resource identifier.
func (levels *EditableLevels) InvalidateResources(modifiedIDs []resource.ID) {
for _, lvl := range levels.list {
lvl.InvalidateResources(modifiedIDs)
}
}
// Level returns the pointer to the given level. The function panics if the index is invalid.
func (levels *EditableLevels) Level(index int) *level.Level {
if !levels.IsLevelAvailable(index) {
panic("Invalid level index")
}
return levels.list[index]
}
// IsLevelReadOnly returns true if the identified level cannot be modified.
func (levels *EditableLevels) IsLevelReadOnly(index int) bool {
isInMod := len(levels.mod.ModifiedBlocks(resource.LangAny, ids.LevelResourcesStart.Plus(lvlids.PerLevel*index+lvlids.FirstUsed))) > 0
return !levels.IsLevelAvailable(index) || !isInMod
}
// IsLevelAvailable returns true if the given index is a valid level.
func (levels *EditableLevels) IsLevelAvailable(index int) bool {
return (index >= 0) && (index < len(levels.list))
}
// IsObjectInUse returns true if the given object ID is in use in given level.
func (levels *EditableLevels) IsObjectInUse(levelIndex int, id level.ObjectID) bool {
if !levels.IsLevelAvailable(levelIndex) {
return false
}
lvl := levels.list[levelIndex]
obj := lvl.Object(id)
return (obj != nil) && (obj.InUse != 0)
}
// IsTileOnMap returns true if the given tile position points to a valid tile in the given level.
func (levels *EditableLevels) IsTileOnMap(levelIndex int, pos level.TilePosition) bool {
if !levels.IsLevelAvailable(levelIndex) {
return false
}
lvl := levels.list[levelIndex]
return lvl.Tile(pos) != nil
}
// CommitLevelChanges applies any level data change compared to the underlying mod.
func (levels *EditableLevels) CommitLevelChanges(index int) error {
lvl := levels.Level(index)
newDataSet := lvl.EncodeState()
var patches []world.BlockPatch
for id, newData := range &newDataSet {
if len(newData) > 0 {
resourceID := ids.LevelResourcesStart.Plus(lvlids.PerLevel*lvl.ID() + id)
patch, changed, err := levels.mod.CreateBlockPatch(resource.LangAny, resourceID, 0, newData)
if err != nil {
return err
} else if changed {
patches = append(patches, patch)
}
}
}
return levels.registry.Register(cmd.Named("CommitLevelChanges"),
cmd.Forward(forwardTask(patches)), cmd.Reverse(reverseTask(patches)))
}
func forwardTask(patches []world.BlockPatch) cmd.Task {
return func(modder world.Modder) error {
for _, patch := range patches {
modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ForwardData)
}
return nil
}
}
func reverseTask(patches []world.BlockPatch) cmd.Task {
return func(modder world.Modder) error {
for _, patch := range patches {
modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ReverseData)
}
return nil
}
}
@@ -0,0 +1,64 @@
package edit
import (
"github.com/inkyblackness/hacked/ss1/content/object"
"github.com/inkyblackness/hacked/ss1/world"
)
// GameObjectBitmapInfo contains indices about bitmaps for a particular game object.
type GameObjectBitmapInfo struct {
// Start is the index for the first object bitmap in ids.ObjectBitmaps.
Start int
// Count is the amount of bitmaps the object has in total.
Count int
// IconRecommendation is the offset from start for using icons.
IconRecommendation int
}
// GameObjectsService provides information for game objects.
type GameObjectsService struct {
mod *world.Mod
}
// NewGameObjectsService returns a new instance.
func NewGameObjectsService(mod *world.Mod) *GameObjectsService {
return &GameObjectsService{
mod: mod,
}
}
// AllProperties returns the currently know properties.
func (service *GameObjectsService) AllProperties() object.PropertiesTable {
return service.mod.ObjectProperties()
}
// PropertiesFor returns the properties for the given triple.
func (service *GameObjectsService) PropertiesFor(triple object.Triple) (object.Properties, error) {
prop, err := service.AllProperties().ForObject(triple)
if err != nil {
return object.Properties{}, err
}
return *prop, err
}
// BitmapInfo returns information on all bitmaps.
func (service *GameObjectsService) BitmapInfo() map[object.Triple]GameObjectBitmapInfo {
properties := service.AllProperties()
result := make(map[object.Triple]GameObjectBitmapInfo)
start := 0
properties.Iterate(func(triple object.Triple, prop *object.Properties) bool {
numExtra := int(prop.Common.Bitmap3D.FrameNumber())
info := GameObjectBitmapInfo{
Start: start,
Count: 3 + numExtra,
IconRecommendation: 1,
}
if triple.Class != object.ClassTrap {
info.IconRecommendation += 2
}
start += info.Count
result[triple] = info
return true
})
return result
}
+272
View File
@@ -0,0 +1,272 @@
package edit
import (
"fmt"
"github.com/inkyblackness/hacked/ss1/content/archive"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/citadel"
)
// VariableBaseContextIdentifier identifies which context for variable naming should be used.
type VariableBaseContextIdentifier int
// Context identifier are listed below.
const (
VariableContextCitadel = VariableBaseContextIdentifier(0)
VariableContextEngine = VariableBaseContextIdentifier(1)
)
// VariableContextIdentifiers returns a list of all available identifiers.
func VariableContextIdentifiers() []VariableBaseContextIdentifier {
return []VariableBaseContextIdentifier{VariableContextCitadel, VariableContextEngine}
}
// GameStateSettings describe the game state details.
type GameStateSettings struct {
BaseContext int
BooleanVariables []VariableSettings
IntegerVariables []VariableSettings
}
// VariableSettings describe game variables.
type VariableSettings struct {
Index int
ResetValue *int16 `json:",omitempty"`
Name string
Description string
Limits *struct {
Minimum int16
Maximum int16
} `json:",omitempty"`
ValueNames []struct {
Value int16
Name string
} `json:",omitempty"`
}
func variableSettingsFrom(variables archive.GameVariables) []VariableSettings {
settings := make([]VariableSettings, 0, len(variables))
for varIndex, varInfo := range variables {
varSettings := VariableSettings{
Index: varIndex,
ResetValue: varInfo.InitValue,
Name: varInfo.Name,
Description: varInfo.Description,
Limits: nil,
ValueNames: nil,
}
if varInfo.Limits != nil {
varSettings.Limits = &struct {
Minimum int16
Maximum int16
}{
Minimum: varInfo.Limits.Minimum,
Maximum: varInfo.Limits.Maximum,
}
}
for valIndex, valName := range varInfo.ValueNames {
varSettings.ValueNames = append(varSettings.ValueNames,
struct {
Value int16
Name string
}{
Value: valIndex,
Name: valName,
})
}
settings = append(settings, varSettings)
}
return settings
}
func variablesFrom(list []VariableSettings) archive.GameVariables {
variables := make(archive.GameVariables)
for _, settings := range list {
varInfo := archive.GameVariableInfo{
InitValue: settings.ResetValue,
Name: settings.Name,
Description: settings.Description,
}
if settings.Limits != nil {
varInfo.Limits = &archive.GameVariableLimits{
Minimum: settings.Limits.Maximum,
Maximum: settings.Limits.Minimum,
}
}
if len(settings.ValueNames) > 0 {
varInfo.ValueNames = make(map[int16]string)
for _, valName := range settings.ValueNames {
varInfo.ValueNames[valName.Value] = valName.Name
}
}
variables[settings.Index] = varInfo
}
return variables
}
// GameStateService handles all details on the global game state.
type GameStateService struct {
registry cmd.Registry
currentContext VariableBaseContextIdentifier
booleanVariables archive.GameVariables
integerVariables archive.GameVariables
}
// NewGameStateService returns a new instance.
func NewGameStateService(registry cmd.Registry) *GameStateService {
return &GameStateService{
registry: registry,
currentContext: VariableContextCitadel,
booleanVariables: make(archive.GameVariables),
integerVariables: make(archive.GameVariables),
}
}
// CurrentSettings returns the snapshot of the project.
func (service GameStateService) CurrentSettings() GameStateSettings {
return GameStateSettings{
BaseContext: int(service.currentContext),
BooleanVariables: variableSettingsFrom(service.booleanVariables),
IntegerVariables: variableSettingsFrom(service.integerVariables),
}
}
// RestoreSettings loads the given snapshot.
func (service *GameStateService) RestoreSettings(settings GameStateSettings) {
service.currentContext = VariableBaseContextIdentifier(settings.BaseContext)
service.booleanVariables = variablesFrom(settings.BooleanVariables)
service.integerVariables = variablesFrom(settings.IntegerVariables)
}
// VariableBaseContext returns the current context to use as basis for variables not specified by the project.
func (service GameStateService) VariableBaseContext() VariableBaseContextIdentifier {
return service.currentContext
}
// SetVariableBaseContext sets the current context to use as basis for variables not specified by the project.
func (service *GameStateService) SetVariableBaseContext(identifier VariableBaseContextIdentifier) {
service.currentContext = identifier
}
func (service GameStateService) baseInfoProvider() archive.GameVariableInfoProvider {
switch service.currentContext {
case VariableContextCitadel:
return citadel.MissionVariables{}
case VariableContextEngine:
return archive.EngineVariables{}
default:
panic(fmt.Sprintf("invalid identifier: %d", service.currentContext))
}
}
// DefaultAllVariables removes all project specific overrides.
func (service *GameStateService) DefaultAllVariables() error {
return service.registry.Register(cmd.Named("RemoveAllOverrides"),
cmd.Nested(func() error {
for i := 0; i < archive.BooleanVarCount; i++ {
err := service.DefaultBooleanVariable(i)
if err != nil {
return err
}
}
for i := 0; i < archive.IntegerVarCount; i++ {
err := service.DefaultIntegerVariable(i)
if err != nil {
return err
}
}
return nil
}))
}
// IntegerVariableOverride returns true if project-specific details are stored for given variable index.
func (service GameStateService) IntegerVariableOverride(index int) bool {
_, perProject := service.integerVariables[index]
return perProject
}
// IntegerVariable returns the variable info as per project settings for the given index.
func (service GameStateService) IntegerVariable(index int) archive.GameVariableInfo {
baseInfo := service.baseInfoProvider().IntegerVariable(index)
projInfo, perProject := service.integerVariables[index]
if perProject {
projInfo.Hardcoded = baseInfo.Hardcoded
return projInfo
}
return baseInfo
}
// SetIntegerVariable sets the variable info for the given index in the project settings.
func (service *GameStateService) SetIntegerVariable(index int, info archive.GameVariableInfo) error {
return service.registry.Register(cmd.Named("SetIntegerVariable"),
cmd.Forward(setVariableTask(service.integerVariables, index, info)),
cmd.Reverse(restoreVariableTask(service.integerVariables, index)))
}
// DefaultIntegerVariable clears the variable info for the given index in the project settings.
func (service *GameStateService) DefaultIntegerVariable(index int) error {
return service.registry.Register(cmd.Named("DefaultIntegerVariable"),
cmd.Forward(deleteVariableTask(service.integerVariables, index)),
cmd.Reverse(restoreVariableTask(service.integerVariables, index)))
}
// BooleanVariableOverride returns true if project-specific details are stored for given variable index.
func (service GameStateService) BooleanVariableOverride(index int) bool {
_, perProject := service.booleanVariables[index]
return perProject
}
// BooleanVariable returns the variable info as per project settings for the given index.
func (service GameStateService) BooleanVariable(index int) archive.GameVariableInfo {
baseInfo := service.baseInfoProvider().BooleanVariable(index)
projInfo, perProject := service.booleanVariables[index]
if perProject {
projInfo.Hardcoded = baseInfo.Hardcoded
return projInfo
}
return baseInfo
}
// SetBooleanVariable sets the variable info for the given index in the project settings.
func (service *GameStateService) SetBooleanVariable(index int, info archive.GameVariableInfo) error {
return service.registry.Register(cmd.Named("SetBooleanVariable"),
cmd.Forward(setVariableTask(service.booleanVariables, index, info)),
cmd.Reverse(restoreVariableTask(service.booleanVariables, index)))
}
// DefaultBooleanVariable clears the variable info for the given index in the project settings.
func (service *GameStateService) DefaultBooleanVariable(index int) error {
return service.registry.Register(cmd.Named("DefaultBooleanVariable"),
cmd.Forward(deleteVariableTask(service.booleanVariables, index)),
cmd.Reverse(restoreVariableTask(service.booleanVariables, index)))
}
func setVariableTask(variables archive.GameVariables, index int, info archive.GameVariableInfo) cmd.Task {
return func(modder world.Modder) error {
variables[index] = info
return nil
}
}
func deleteVariableTask(variables archive.GameVariables, index int) cmd.Task {
return func(modder world.Modder) error {
delete(variables, index)
return nil
}
}
func restoreVariableTask(variables archive.GameVariables, index int) cmd.Task {
info, present := variables[index]
if present {
return setVariableTask(variables, index, info)
}
return deleteVariableTask(variables, index)
}
+55
View File
@@ -0,0 +1,55 @@
package edit
import (
"fmt"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
// TextInfo describes one kind of text.
type TextInfo struct {
ID resource.ID
Title string
audioBase resource.ID
}
// TextInfoList is a set of TextInfo.
type TextInfoList []TextInfo
// ByID returns a text info for the given identifier.
func (list TextInfoList) ByID(id resource.ID) TextInfo {
for _, info := range list {
if info.ID == id {
return info
}
}
return TextInfo{
ID: id,
Title: fmt.Sprintf("??? %v", id),
}
}
// Title returns the title property of the identified info.
func (list TextInfoList) Title(id resource.ID) string {
return list.ByID(id).Title
}
var knownTexts = TextInfoList{
{ID: ids.TrapMessageTexts, Title: "Trap Messages", audioBase: ids.TrapMessagesAudioStart},
{ID: ids.WordTexts, Title: "Words"},
{ID: ids.LogCategoryTexts, Title: "Log Categories"},
{ID: ids.VariousMessageTexts, Title: "Various Messages"},
{ID: ids.ScreenMessageTexts, Title: "Screen Messages"},
{ID: ids.InfoNodeMessageTexts, Title: "Info Node Message Texts (8/5/6)"},
{ID: ids.AccessCardNameTexts, Title: "Access Card Names"},
{ID: ids.DataletMessageTexts, Title: "Datalet Messages (8/5/8)"},
{ID: ids.PaperTextsStart, Title: "Papers"},
{ID: ids.PanelNameTexts, Title: "Panel Names"},
}
// KnownTexts returns a set of known texts.
func KnownTexts() TextInfoList {
return knownTexts
}
@@ -0,0 +1,257 @@
package edit
import (
"github.com/inkyblackness/hacked/ss1/content/archive/level"
"github.com/inkyblackness/hacked/ss1/content/object"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
// LevelEditorService provides level editing functionality based on the currently selected level and content.
type LevelEditorService struct {
registry cmd.Registry
gameObjects *GameObjectsService
levels *EditableLevels
levelSelection *LevelSelectionService
newObjectTriple object.Triple
}
// NewLevelEditorService returns a new instance.
func NewLevelEditorService(registry cmd.Registry,
gameObjects *GameObjectsService,
levels *EditableLevels,
levelSelection *LevelSelectionService) *LevelEditorService {
return &LevelEditorService{
registry: registry,
gameObjects: gameObjects,
levels: levels,
levelSelection: levelSelection,
}
}
// IsReadOnly returns true if the currently selected level cannot be modified.
func (service *LevelEditorService) IsReadOnly() bool {
return service.levels.IsLevelReadOnly(service.levelSelection.CurrentLevelID())
}
// Level returns the currently selected level.
func (service *LevelEditorService) Level() *level.Level {
return service.levels.Level(service.levelSelection.CurrentLevelID())
}
// Tiles returns the list of currently selected tiles of the current level.
func (service *LevelEditorService) Tiles() []*level.TileMapEntry {
lvl := service.Level()
positions := service.levelSelection.CurrentSelectedTiles()
tiles := make([]*level.TileMapEntry, len(positions))
for i, pos := range positions {
tiles[i] = lvl.Tile(pos)
}
return tiles
}
// HasSelectedTiles returns true if Tiles() returns at least one entry.
func (service *LevelEditorService) HasSelectedTiles() bool {
return len(service.levelSelection.CurrentSelectedTiles()) > 0
}
// ClearTiles resets the selected tiles.
func (service *LevelEditorService) ClearTiles() error {
return service.ChangeTiles(func(tile *level.TileMapEntry) {
index := tile.FirstObjectIndex
tile.Reset()
tile.FirstObjectIndex = index
})
}
// ChangeTiles performs a modification on all currently selected tiles and commits these changes to the repository.
func (service *LevelEditorService) ChangeTiles(modifier func(*level.TileMapEntry)) error {
positions := service.levelSelection.CurrentSelectedTiles()
if len(positions) == 0 {
return nil
}
lvl := service.Level()
for _, pos := range positions {
modifier(lvl.Tile(pos))
}
levelID := lvl.ID()
return service.registry.Register(cmd.Named("ChangeTiles"),
cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)),
cmd.Forward(service.setSelectedTilesTask(positions)),
cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }),
cmd.Reverse(service.setSelectedTilesTask(positions)),
cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)),
)
}
// Objects returns the list of currently selected objects of the current level.
func (service *LevelEditorService) Objects() []*level.ObjectMainEntry {
lvl := service.Level()
ids := service.levelSelection.CurrentSelectedObjects()
objects := make([]*level.ObjectMainEntry, len(ids))
for i, id := range ids {
objects[i] = lvl.Object(id)
}
return objects
}
// HasSelectedObjects returns true if a call to Objects() returns at least one item.
func (service *LevelEditorService) HasSelectedObjects() bool {
return len(service.levelSelection.CurrentSelectedObjects()) > 0
}
// NewObjectTriple returns the identifier of implicitly created objects.
func (service *LevelEditorService) NewObjectTriple() object.Triple {
return service.newObjectTriple
}
// SetNewObjectTriple sets the identifier for implicitly created objects.
func (service *LevelEditorService) SetNewObjectTriple(value object.Triple) {
service.newObjectTriple = value
}
// CreateNewObject adds a new object to the level and selects it.
func (service *LevelEditorService) CreateNewObject(triple object.Triple, modifier level.ObjectMainEntryModifier) error {
lvl := service.Level()
id, err := lvl.NewObject(triple.Class)
if err != nil {
return err
}
obj := lvl.Object(id)
obj.Subclass = triple.Subclass
obj.Type = triple.Type
service.resetHitpointsToDefault(obj)
modifier(obj)
service.placeObject(lvl, obj, service.atFloorLevelIn(lvl))
lvl.UpdateObjectLocation(id)
return service.patchLevelObjects(lvl, service.levelSelection.CurrentSelectedObjects(), []level.ObjectID{id})
}
// PlaceObjectsOnFloor puts all selected objects to sit on the floor.
func (service *LevelEditorService) PlaceObjectsOnFloor() error {
lvl := service.Level()
atHeight := service.atFloorLevelIn(lvl)
return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) })
}
// PlaceObjectsOnEyeLevel puts all selected objects to be at eye level (approximately).
func (service *LevelEditorService) PlaceObjectsOnEyeLevel() error {
lvl := service.Level()
atHeight := service.atEyeLevelIn(lvl)
return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) })
}
// PlaceObjectsOnCeiling puts all selected objects to hang from the ceiling.
func (service *LevelEditorService) PlaceObjectsOnCeiling() error {
lvl := service.Level()
atHeight := service.atCeilingLevelIn(lvl)
return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) })
}
// ChangeObjects modifies properties of selected objects. The modifier is called for each selected object.
func (service *LevelEditorService) ChangeObjects(modifier level.ObjectMainEntryModifier) error {
objectIDs := service.levelSelection.CurrentSelectedObjects()
if len(objectIDs) == 0 {
return nil
}
lvl := service.Level()
for _, id := range objectIDs {
obj := lvl.Object(id)
oldPosition := obj.TilePosition()
modifier(obj)
if oldPosition != obj.TilePosition() {
lvl.UpdateObjectLocation(id)
}
}
return service.patchLevelObjects(lvl, objectIDs, objectIDs)
}
// DeleteObjects deletes all currently selected objects, clearing the selection afterwards.
func (service *LevelEditorService) DeleteObjects() error {
objectIDs := service.levelSelection.CurrentSelectedObjects()
if len(objectIDs) == 0 {
return nil
}
lvl := service.Level()
for _, id := range objectIDs {
lvl.DelObject(id)
}
return service.patchLevelObjects(lvl, objectIDs, nil)
}
func (service *LevelEditorService) patchLevelObjects(
lvl *level.Level,
reverseObjectIDs []level.ObjectID,
forwardObjectIDs []level.ObjectID) error {
levelID := lvl.ID()
return service.registry.Register(cmd.Named("PatchLevelObjects"),
cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)),
cmd.Reverse(service.setSelectedObjectsTask(reverseObjectIDs)),
cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }),
cmd.Forward(service.setSelectedObjectsTask(forwardObjectIDs)),
cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)),
)
}
func (service *LevelEditorService) setSelectedTilesTask(positions []level.TilePosition) cmd.Task {
return func(world.Modder) error {
service.levelSelection.SetCurrentSelectedTiles(positions)
return nil
}
}
func (service *LevelEditorService) setSelectedObjectsTask(ids []level.ObjectID) cmd.Task {
return func(world.Modder) error {
service.levelSelection.SetCurrentSelectedObjects(ids)
return nil
}
}
type heightCalculatorFunc func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit
func (service *LevelEditorService) placeObject(lvl *level.Level, obj *level.ObjectMainEntry, atHeight heightCalculatorFunc) {
var objPivot float32
prop, err := service.gameObjects.PropertiesFor(obj.Triple())
if err == nil {
objPivot = object.Pivot(prop.Common)
}
tile := lvl.Tile(obj.TilePosition())
if tile != nil {
obj.Z = atHeight(tile, obj.FinePosition(), objPivot)
}
}
func (service *LevelEditorService) atFloorLevelIn(lvl *level.Level) heightCalculatorFunc {
_, _, height := lvl.Size()
return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit {
floorHeight := tile.FloorTileHeightAt(pos, height)
return height.ValueToObjectHeight(floorHeight + objPivot)
}
}
func (service *LevelEditorService) atEyeLevelIn(lvl *level.Level) heightCalculatorFunc {
_, _, height := lvl.Size()
return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit {
floorHeight := tile.FloorTileHeightAt(pos, height)
return height.ValueToObjectHeight(floorHeight + 0.75 - objPivot)
}
}
func (service *LevelEditorService) atCeilingLevelIn(lvl *level.Level) heightCalculatorFunc {
_, _, height := lvl.Size()
return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit {
floorHeight := tile.CeilingTileHeightAt(pos, height)
return height.ValueToObjectHeight(floorHeight - objPivot)
}
}
func (service *LevelEditorService) resetHitpointsToDefault(obj *level.ObjectMainEntry) {
prop, err := service.gameObjects.PropertiesFor(obj.Triple())
if err != nil {
return
}
obj.Hitpoints = prop.Common.Hitpoints
}
@@ -0,0 +1,231 @@
package edit
import (
"sort"
"github.com/inkyblackness/hacked/ss1/content/archive/level"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
type levelSelection struct {
tiles map[level.TilePosition]struct{}
objects map[level.ObjectID]struct{}
}
func (selection levelSelection) tileList() []level.TilePosition {
list := make([]level.TilePosition, 0, len(selection.tiles))
for pos := range selection.tiles {
list = append(list, pos)
}
sort.Slice(list, func(a, b int) bool {
posA := list[a]
posB := list[b]
return (posA.Y < posB.Y) || ((posA.Y == posB.Y) && (posA.X < posB.X))
})
return list
}
func (selection levelSelection) objectList() []level.ObjectID {
list := make([]level.ObjectID, 0, len(selection.objects))
for id := range selection.objects {
list = append(list, id)
}
sort.Slice(list, func(a, b int) bool { return list[a] < list[b] })
return list
}
// LevelInfoProvider returns details on level content.
type LevelInfoProvider interface {
IsLevelAvailable(index int) bool
IsObjectInUse(levelIndex int, id level.ObjectID) bool
IsTileOnMap(levelIndex int, pos level.TilePosition) bool
}
// LevelSelectionService keeps track of selections related to levels.
type LevelSelectionService struct {
provider LevelInfoProvider
currentLevel int
levels map[int]*levelSelection
}
// NewLevelSelectionService returns a new instance.
func NewLevelSelectionService(provider LevelInfoProvider) *LevelSelectionService {
return &LevelSelectionService{
provider: provider,
currentLevel: 0,
levels: make(map[int]*levelSelection),
}
}
// CurrentLevelID returns the identifier for the currently selected level.
func (service *LevelSelectionService) CurrentLevelID() int {
if !service.provider.IsLevelAvailable(service.currentLevel) {
return 0
}
return service.currentLevel
}
// SetCurrentLevelID sets the currently selected level.
func (service *LevelSelectionService) SetCurrentLevelID(index int) {
service.currentLevel = index
}
// SetCurrentLevelIDTask returns a command task that will set the current level.
func (service *LevelSelectionService) SetCurrentLevelIDTask(index int) cmd.Task {
return func(modder world.Modder) error {
service.SetCurrentLevelID(index)
return nil
}
}
func (service *LevelSelectionService) currentSelection() levelSelection {
selection, ok := service.levels[service.currentLevel]
if !ok {
return levelSelection{}
}
return *service.cleanSelection(service.currentLevel, selection)
}
func (service *LevelSelectionService) modifiableSelection() *levelSelection {
selection, ok := service.levels[service.currentLevel]
if !ok {
selection = &levelSelection{
tiles: make(map[level.TilePosition]struct{}),
objects: make(map[level.ObjectID]struct{}),
}
service.levels[service.currentLevel] = selection
}
return service.cleanSelection(service.currentLevel, selection)
}
func (service *LevelSelectionService) cleanSelection(levelIndex int, selection *levelSelection) *levelSelection {
return service.cleanSelectionTiles(levelIndex, service.cleanSelectionObjects(levelIndex, selection))
}
func (service *LevelSelectionService) cleanSelectionTiles(levelIndex int, selection *levelSelection) *levelSelection {
var toDelete []level.TilePosition
for pos := range selection.tiles {
if !service.provider.IsTileOnMap(levelIndex, pos) {
toDelete = append(toDelete, pos)
}
}
for _, pos := range toDelete {
delete(selection.tiles, pos)
}
return selection
}
func (service *LevelSelectionService) cleanSelectionObjects(levelIndex int, selection *levelSelection) *levelSelection {
var toDelete []level.ObjectID
for id := range selection.objects {
if !service.provider.IsObjectInUse(levelIndex, id) {
toDelete = append(toDelete, id)
}
}
for _, id := range toDelete {
delete(selection.objects, id)
}
return selection
}
// IsTileSelected returns true if the given tile is currently selected.
func (service *LevelSelectionService) IsTileSelected(pos level.TilePosition) bool {
_, selected := service.currentSelection().tiles[pos]
return selected
}
// CurrentSelectedTiles returns the list of currently selected tiles in the current level.
func (service *LevelSelectionService) CurrentSelectedTiles() []level.TilePosition {
return service.currentSelection().tileList()
}
// SetCurrentSelectedTiles sets the list of currently selected tiles in the current level.
func (service *LevelSelectionService) SetCurrentSelectedTiles(list []level.TilePosition) {
selection := service.modifiableSelection()
for pos := range selection.tiles {
delete(selection.tiles, pos)
}
for _, pos := range list {
selection.tiles[pos] = struct{}{}
}
}
// AddCurrentSelectedTiles adds the given tile IDs to the list of currently selected in the current level.
func (service *LevelSelectionService) AddCurrentSelectedTiles(list []level.TilePosition) {
selection := service.modifiableSelection()
for _, pos := range list {
selection.tiles[pos] = struct{}{}
}
}
// RemoveCurrentSelectedTiles removes the given tile IDs from the list of currently selected in the current level.
func (service *LevelSelectionService) RemoveCurrentSelectedTiles(list []level.TilePosition) {
selection := service.modifiableSelection()
for _, pos := range list {
delete(selection.tiles, pos)
}
}
// ToggleTileSelection toggles the selection of the given tile IDs in the current level.
func (service *LevelSelectionService) ToggleTileSelection(list []level.TilePosition) {
selection := service.modifiableSelection()
for _, pos := range list {
if _, selected := selection.tiles[pos]; selected {
delete(selection.tiles, pos)
} else {
selection.tiles[pos] = struct{}{}
}
}
}
// NumberOfSelectedObjects returns the number of currently selected objects in the current level.
func (service *LevelSelectionService) NumberOfSelectedObjects() int {
return len(service.currentSelection().objects)
}
// CurrentSelectedObjects returns the list of currently selected objects in the current level.
func (service *LevelSelectionService) CurrentSelectedObjects() []level.ObjectID {
return service.currentSelection().objectList()
}
// SetCurrentSelectedObjects sets the list of currently selected objects in the current level.
func (service *LevelSelectionService) SetCurrentSelectedObjects(ids []level.ObjectID) {
selection := service.modifiableSelection()
for id := range selection.objects {
delete(selection.objects, id)
}
for _, id := range ids {
selection.objects[id] = struct{}{}
}
}
// AddCurrentSelectedObjects adds the given object IDs to the list of currently selected in the current level.
func (service *LevelSelectionService) AddCurrentSelectedObjects(ids []level.ObjectID) {
selection := service.modifiableSelection()
for _, id := range ids {
selection.objects[id] = struct{}{}
}
}
// RemoveCurrentSelectedObjects removes the given object IDs from the list of currently selected in the current level.
func (service *LevelSelectionService) RemoveCurrentSelectedObjects(ids []level.ObjectID) {
selection := service.modifiableSelection()
for _, id := range ids {
delete(selection.objects, id)
}
}
// ToggleObjectSelection toggles the selection of the given object IDs in the current level.
func (service *LevelSelectionService) ToggleObjectSelection(ids []level.ObjectID) {
selection := service.modifiableSelection()
for _, id := range ids {
if _, selected := selection.objects[id]; selected {
delete(selection.objects, id)
} else {
selection.objects[id] = struct{}{}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package edit
import (
"time"
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/resource"
)
// MovieService provides read/write functionality.
type MovieService struct {
cp text.Codepage
movieViewer media.MovieViewerService
movieSetter media.MovieSetterService
}
// NewMovieService returns a new instance based on given accessor.
func NewMovieService(codepage text.Codepage,
movieViewer media.MovieViewerService, movieSetter media.MovieSetterService) MovieService {
return MovieService{
cp: codepage,
movieViewer: movieViewer,
movieSetter: movieSetter,
}
}
// RestoreFunc creates a snapshot of the current movie and returns a function to restore it.
func (service MovieService) RestoreFunc(key resource.Key) func(setter media.MovieBlockSetter) {
oldContainer, _ := service.movieViewer.Container(key)
isModified := service.movieViewer.Modified(key)
return func(setter media.MovieBlockSetter) {
if isModified {
service.movieSetter.Set(setter, key, oldContainer)
} else {
service.movieSetter.Remove(setter, key)
}
}
}
// Remove erases the movie from the resources.
func (service MovieService) Remove(setter media.MovieBlockSetter, key resource.Key) {
service.movieSetter.Remove(setter, key)
}
// SizeWarning returns true if the given movie is larger than the underlying archive would support.
func (service MovieService) SizeWarning(key resource.Key) bool {
return service.movieViewer.SizeWarning(key)
}
// Video returns the video component of identified movie.
func (service MovieService) Video(key resource.Key) []movie.Scene {
return service.movieViewer.Video(key)
}
// MoveSceneEarlier moves the given scene one step earlier.
func (service MovieService) MoveSceneEarlier(setter media.MovieBlockSetter, key resource.Key, scene int) {
service.swapScenes(setter, key, scene, scene-1)
}
// MoveSceneLater moves the given scene one step later.
func (service MovieService) MoveSceneLater(setter media.MovieBlockSetter, key resource.Key, scene int) {
service.swapScenes(setter, key, scene, scene+1)
}
func (service MovieService) swapScenes(setter media.MovieBlockSetter, key resource.Key, sceneA, sceneB int) {
baseContainer := service.getBaseContainer(key)
if (sceneA < 0) || (sceneA >= (len(baseContainer.Video.Scenes))) {
return
}
if (sceneB < 0) || (sceneB >= (len(baseContainer.Video.Scenes))) {
return
}
scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes))
copy(scenes, baseContainer.Video.Scenes)
scenes[sceneA] = baseContainer.Video.Scenes[sceneB]
scenes[sceneB] = baseContainer.Video.Scenes[sceneA]
baseContainer.Video.Scenes = scenes
service.movieSetter.Set(setter, key, baseContainer)
}
// AddScene adds the given scene at the end of the movie.
func (service MovieService) AddScene(setter media.MovieBlockSetter, key resource.Key, scene movie.HighResScene) {
baseContainer := service.getBaseContainer(key)
baseContainer.Video.Scenes = append(baseContainer.Video.Scenes, scene)
service.movieSetter.Set(setter, key, baseContainer)
}
// RemoveScene cuts out the given scene from the movie.
func (service MovieService) RemoveScene(setter media.MovieBlockSetter, key resource.Key, scene int) {
baseContainer := service.getBaseContainer(key)
if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) {
return
}
scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes)-1)
copy(scenes[0:scene], baseContainer.Video.Scenes[0:scene])
copy(scenes[scene:], baseContainer.Video.Scenes[scene+1:])
baseContainer.Video.Scenes = scenes
service.movieSetter.Set(setter, key, baseContainer)
}
// SetSceneFramesDisplayTime sets the display time for each frame.
func (service MovieService) SetSceneFramesDisplayTime(setter media.MovieBlockSetter, key resource.Key,
scene int, displayTime time.Duration) {
baseContainer := service.getBaseContainer(key)
if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) {
return
}
baseContainer.Video.Scenes[scene] = baseContainer.Video.Scenes[scene].WithFrameDisplayTime(displayTime)
service.movieSetter.Set(setter, key, baseContainer)
}
// Audio returns the audio component of identified movie.
func (service MovieService) Audio(key resource.Key) audio.L8 {
return service.movieViewer.Audio(key)
}
// SetAudio sets the audio component of identified movie.
func (service MovieService) SetAudio(setter media.MovieBlockSetter, key resource.Key, soundData audio.L8) {
baseContainer := service.getBaseContainer(key)
baseContainer.Audio.Sound = soundData
service.movieSetter.Set(setter, key, baseContainer)
}
// Subtitles returns the subtitles associated with the given key.
func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList {
return service.movieViewer.Subtitles(key, language)
}
// SetSubtitles sets the subtitles of identified movie in given language.
func (service MovieService) SetSubtitles(setter media.MovieBlockSetter, key resource.Key,
language resource.Language, subtitles movie.SubtitleList) {
baseContainer := service.getBaseContainer(key)
baseContainer.Subtitles.PerLanguage[language] = subtitles
service.movieSetter.Set(setter, key, baseContainer)
}
func (service MovieService) getBaseContainer(key resource.Key) movie.Container {
container, err := service.movieViewer.Container(key)
if err != nil {
container = movie.Container{
Audio: movie.Audio{Sound: audio.L8{SampleRate: 22050}},
Video: movie.Video{
Width: movie.HighResDefaultWidth,
Height: movie.HighResDefaultHeight,
},
}
}
return container
}
+394
View File
@@ -0,0 +1,394 @@
package edit
import (
"bytes"
"os"
"path/filepath"
"time"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/content/object"
"github.com/inkyblackness/hacked/ss1/content/texture"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/resource/lgres"
"github.com/inkyblackness/hacked/ss1/serial"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
const (
errNoResourcesFound ss1.StringError = "no resources found"
errNoStorageLocationSet ss1.StringError = "no storage location set"
)
// ProjectSettings describe the properties of a project.
type ProjectSettings struct {
ModFiles []string
Manifest []ManifestEntrySettings
}
// ManifestEntrySettings describe the properties of one manifest entry in a project.
type ManifestEntrySettings struct {
Origin []string
}
const autosaveTimeoutSec = 5
// SaveStatus describes the current change state.
type SaveStatus struct {
mod *world.Mod
// FilesModified is the count of how many files are affected by the current pending change.
FilesModified int
// SavePending is set if the mod has a storage location and changes are to be saved.
SavePending bool
// SaveIn is the duration after which an auto-save should happen.
SaveIn time.Duration
}
// ConfirmPendingSave marks that the recent auto-save status has been acknowledged and should no longer be notified.
// A new change will re-start the auto-save timer.
func (status SaveStatus) ConfirmPendingSave() {
if status.mod == nil {
return
}
status.mod.ResetLastChangeTime()
}
// ProjectService handles the overall information about the active project.
type ProjectService struct {
commander cmd.Registry
mod *world.Mod
modPath string
stateFilename string
}
// NewProjectService returns a new instance of a service for given mod.
func NewProjectService(commander cmd.Registry, mod *world.Mod) *ProjectService {
return &ProjectService{
commander: commander,
mod: mod,
}
}
// SaveStatus returns the current pending save information.
func (service ProjectService) SaveStatus() SaveStatus {
var status SaveStatus
status.mod = service.mod
status.FilesModified = len(service.mod.ModifiedFilenames())
if status.FilesModified > 0 {
lastChangeTime := service.mod.LastChangeTime()
if service.ModHasStorageLocation() && !lastChangeTime.IsZero() {
status.SavePending = true
saveAt := lastChangeTime.Add(time.Duration(autosaveTimeoutSec) * time.Second)
status.SaveIn = time.Until(saveAt)
if status.SaveIn <= 0 {
status.SaveIn = 0
}
}
}
return status
}
// CurrentStateFilename returns the name for the state of the project.
func (service ProjectService) CurrentStateFilename() string {
return service.stateFilename
}
// SetCurrentStateFilename updates the current filename.
func (service *ProjectService) SetCurrentStateFilename(value string) {
service.stateFilename = value
}
// CurrentSettings returns the snapshot of the project.
func (service ProjectService) CurrentSettings() ProjectSettings {
manifest := service.mod.World()
settings := ProjectSettings{Manifest: make([]ManifestEntrySettings, manifest.EntryCount())}
for i := 0; i < manifest.EntryCount(); i++ {
entry, _ := manifest.Entry(i)
settings.Manifest[i].Origin = entry.Origin
}
settings.ModFiles = service.relativeToSettings(service.mod.AllAbsoluteFilenames(service.modPath)...)
return settings
}
func (service *ProjectService) relativeToSettings(filenames ...string) []string {
loc := world.FileLocationFrom(service.stateFilename)
relatives := make([]string, 0, len(filenames))
for _, filename := range filenames {
relatives = append(relatives, world.FileLocationFrom(filename).NestedRelativeTo(loc.DirPath))
}
return relatives
}
func (service *ProjectService) absoluteFromSettings(filenames ...string) []string {
loc := world.FileLocationFrom(service.stateFilename)
absolutes := make([]string, 0, len(filenames))
for _, filename := range filenames {
absolutes = append(absolutes, world.FileLocationFrom(filename).AbsolutePathFrom(loc.DirPath))
}
return absolutes
}
// RestoreProject sets internal data based on the given settings.
func (service *ProjectService) RestoreProject(settings ProjectSettings, stateFilename string) {
service.ResetProject()
service.stateFilename = stateFilename
manifest := service.mod.World()
for _, entrySettings := range settings.Manifest {
entry, err := world.NewManifestEntryFrom(entrySettings.Origin)
if err != nil {
continue
}
err = manifest.InsertEntry(manifest.EntryCount(), entry)
if err != nil {
continue
}
}
_ = service.TryLoadModFrom(service.absoluteFromSettings(settings.ModFiles...))
}
// ResetProject clears the project and returns it to initial state.
func (service *ProjectService) ResetProject() {
service.setActiveMod("", nil, nil, nil)
service.mod.World().Reset()
service.stateFilename = ""
}
// AddManifestEntry attempts to insert the given manifest entry at given index.
func (service *ProjectService) AddManifestEntry(at int, entry *world.ManifestEntry) error {
return service.commander.Register(
cmd.Named("AddManifestEntry"),
cmd.Forward(func(modder world.Modder) error {
return service.mod.World().InsertEntry(at, entry)
}),
cmd.Reverse(func(modder world.Modder) error {
return service.mod.World().RemoveEntry(at)
}),
)
}
// RemoveManifestEntry attempts to remove the manifest entry at given index.
func (service *ProjectService) RemoveManifestEntry(at int) error {
manifest := service.mod.World()
entry, err := manifest.Entry(at)
if err != nil {
return err
}
return service.commander.Register(
cmd.Named("RemoveManifestEntry"),
cmd.Forward(func(modder world.Modder) error {
return manifest.RemoveEntry(at)
}),
cmd.Reverse(func(modder world.Modder) error {
return manifest.InsertEntry(at, entry)
}),
)
}
// MoveManifestEntry attempts to remove the manifest entry at given from index and re-insert it at given to index.
func (service *ProjectService) MoveManifestEntry(to, from int) error {
return service.commander.Register(
cmd.Named("MoveManifestEntry"),
cmd.Forward(func(modder world.Modder) error {
return service.mod.World().MoveEntry(to, from)
}),
cmd.Reverse(func(modder world.Modder) error {
return service.mod.World().MoveEntry(from, to)
}),
)
}
// Mod returns the currently active mod in the project.
func (service ProjectService) Mod() *world.Mod {
return service.mod
}
// NewMod resets the mod to a new state.
func (service *ProjectService) NewMod() {
service.setActiveMod("", nil, nil, nil)
}
// TryLoadModFrom attempts to set the active mod from given filenames.
func (service *ProjectService) TryLoadModFrom(names []string) error {
loaded := world.LoadFiles(false, names)
resourcesToTake := loaded.Resources
isSavegame := false
if (len(resourcesToTake) == 0) && (len(loaded.Savegames) == 1) {
resourcesToTake = loaded.Savegames
isSavegame = true
}
if len(resourcesToTake) == 0 {
return errNoResourcesFound
}
var locs []*world.LocalizedResources
modPath := ""
for location := range resourcesToTake {
if (len(modPath) == 0) || (len(location.DirPath) < len(modPath)) {
modPath = location.DirPath
}
}
for location, viewer := range resourcesToTake {
lang := ids.LocalizeFilename(location.Name)
template := location.Name
if isSavegame {
template = string(ids.Archive)
}
loc := &world.LocalizedResources{
File: location,
Template: template,
Language: lang,
}
for _, id := range viewer.IDs() {
view, err := viewer.View(id)
if err != nil {
// optimistic ignore
continue
}
_ = loc.Store.Put(id, view)
}
locs = append(locs, loc)
}
service.setActiveMod(modPath, locs, loaded.ObjectProperties, loaded.TextureProperties)
return nil
}
func (service *ProjectService) setActiveMod(modPath string, resources []*world.LocalizedResources,
objectProperties object.PropertiesTable, textureProperties texture.PropertiesList) {
service.setModPath(modPath)
service.mod.Reset(resources, objectProperties, textureProperties)
// fix list resources for any "old" mod.
service.mod.FixListResources()
}
// ModifyModWith runs a function with the intent to alter the current mod.
func (service *ProjectService) ModifyModWith(modifier func(world.Modder) error) (err error) {
service.mod.Modify(func(modder world.Modder) {
err = modifier(modder)
})
return
}
// ModHasStorageLocation returns whether the mod has a place to be stored.
func (service ProjectService) ModHasStorageLocation() bool {
return len(service.modPath) > 0
}
// ModPath returns the base path of the mod in the project.
func (service ProjectService) ModPath() string {
return service.modPath
}
func (service *ProjectService) setModPath(value string) {
service.modPath = value
}
// SaveMod will store the currently active mod in its current path.
func (service *ProjectService) SaveMod() error {
if !service.ModHasStorageLocation() {
return errNoStorageLocationSet
}
return service.SaveModUnder(service.modPath)
}
// SaveModUnder will store the currently active mod in the given path.
func (service *ProjectService) SaveModUnder(modPath string) error {
service.mod.FixListResources()
err := service.saveModResourcesTo(modPath)
if err != nil {
return err
}
service.setModPath(modPath)
service.mod.MarkSave()
return nil
}
func (service *ProjectService) saveModResourcesTo(modPath string) error {
localized := service.mod.ModifiedResources()
filenamesToSave := service.mod.ModifiedFilenames()
shallBeSaved := func(filename string) bool {
for _, toSave := range filenamesToSave {
if toSave == filename {
return true
}
}
return false
}
for _, loc := range localized {
if shallBeSaved(loc.File.Name) {
err := saveResourcesTo(loc.Store, loc.File.AbsolutePathFrom(modPath))
if err != nil {
return err
}
}
}
if shallBeSaved(world.TexturePropertiesFilename) {
err := saveTexturePropertiesTo(service.mod.TextureProperties(), filepath.Join(modPath, world.TexturePropertiesFilename))
if err != nil {
return err
}
}
if shallBeSaved(world.ObjectPropertiesFilename) {
err := saveObjectPropertiesTo(service.mod.ObjectProperties(), filepath.Join(modPath, world.ObjectPropertiesFilename))
if err != nil {
return err
}
}
return nil
}
func saveResourcesTo(viewer resource.Viewer, absFilename string) error {
file, err := os.Create(absFilename)
if err != nil {
return err
}
defer func() {
_ = file.Close() // nolint: gas
}()
err = lgres.Write(file, viewer)
return err
}
func saveTexturePropertiesTo(list texture.PropertiesList, absFilename string) error {
return saveCodableTo(list, absFilename)
}
func saveObjectPropertiesTo(list object.PropertiesTable, absFilename string) error {
return saveCodableTo(list, absFilename)
}
func saveCodableTo(codable serial.Codable, absFilename string) error {
buffer := bytes.NewBuffer(nil)
encoder := serial.NewEncoder(buffer)
codable.Code(encoder)
err := encoder.FirstError()
if err != nil {
return err
}
file, err := os.Create(absFilename)
if err != nil {
return err
}
defer func() {
_ = file.Close() // nolint: gas
}()
_, err = file.Write(buffer.Bytes())
return err
}
@@ -0,0 +1,61 @@
package edit
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/resource"
)
// SoundEffectService provides read/write functionality.
type SoundEffectService struct {
viewer media.SoundEffectViewerService
setter media.SoundEffectSetterService
}
// NewSoundEffectService returns a new instance based on given accessor.
func NewSoundEffectService(
viewer media.SoundEffectViewerService, setter media.SoundEffectSetterService) SoundEffectService {
return SoundEffectService{
viewer: viewer,
setter: setter,
}
}
// RestoreFunc creates a snapshot of the current sound and returns a function to restore it.
func (service SoundEffectService) RestoreFunc(key resource.Key) func(setter media.SoundEffectBlockSetter) {
oldAudio := service.viewer.Audio(key)
isModified := service.viewer.Modified(key)
return func(setter media.SoundEffectBlockSetter) {
if isModified {
service.setter.Set(setter, key, oldAudio)
} else {
service.setter.Remove(setter, key)
}
}
}
// Modified returns true if the identified sound resource is marked as modified.
func (service SoundEffectService) Modified(key resource.Key) bool {
return service.viewer.Modified(key)
}
// Remove erases the sound from the resources.
func (service SoundEffectService) Remove(setter media.SoundEffectBlockSetter, key resource.Key) {
service.setter.Remove(setter, key)
}
// Clear resets the identified audio resource to a silent one-sample audio.
func (service SoundEffectService) Clear(setter media.SoundEffectBlockSetter, key resource.Key) {
service.setter.Clear(setter, key)
}
// Audio returns the audio component of identified sound effect.
func (service SoundEffectService) Audio(key resource.Key) audio.L8 {
return service.viewer.Audio(key)
}
// SetAudio sets the audio component of identified sound effect.
func (service SoundEffectService) SetAudio(setter media.SoundEffectBlockSetter, key resource.Key, data audio.L8) {
service.setter.Set(setter, key, data)
}
+3
View File
@@ -0,0 +1,3 @@
// Package edit contains logic for creating and updating game content.
// The services in this package modify resources directly.
package edit
@@ -0,0 +1,39 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/resource"
)
// AudioBlockSetter modifies storage of raw resource data.
type AudioBlockSetter interface {
SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte)
DelResource(lang resource.Language, id resource.ID)
}
// AudioSetterService provides methods to change audio resources.
type AudioSetterService struct{}
// NewAudioSetterService returns a new instance.
func NewAudioSetterService() AudioSetterService {
return AudioSetterService{}
}
// Remove deletes any audio resource for given key.
func (service AudioSetterService) Remove(setter AudioBlockSetter, key resource.Key) {
setter.DelResource(key.Lang, key.ID)
}
// Clear resets the identified audio resource to a silent one-sample audio.
func (service AudioSetterService) Clear(setter AudioBlockSetter, key resource.Key) {
silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}}
service.Set(setter, key, silence)
}
// Set stores the given sound as the identified resource.
func (service AudioSetterService) Set(setter AudioBlockSetter, key resource.Key, sound audio.L8) {
movieData := movie.ContainSoundData(sound)
blockData := [][]byte{movieData}
setter.SetResourceBlocks(key.Lang, key.ID, blockData)
}
@@ -0,0 +1,38 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/resource"
)
// AudioBlockGetter provides raw data of blocks.
type AudioBlockGetter interface {
ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte
}
// AudioViewerService provides read-only access to audio resources.
type AudioViewerService struct {
movieCache *movie.Cache
getter AudioBlockGetter
}
// NewAudioViewerService returns a new instance.
func NewAudioViewerService(movieCache *movie.Cache, getter AudioBlockGetter) AudioViewerService {
return AudioViewerService{
movieCache: movieCache,
getter: getter,
}
}
// Audio returns the sound data associated with the given key.
func (service AudioViewerService) Audio(key resource.Key) audio.L8 {
sound, _ := service.movieCache.Audio(key)
return sound
}
// Modified returns true if the identified audio resource is marked as modified.
func (service AudioViewerService) Modified(key resource.Key) bool {
data := service.getter.ModifiedBlock(key.Lang, key.ID, 0)
return len(data) > 0
}
@@ -0,0 +1,37 @@
package media
import (
"bytes"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/resource"
)
// MovieBlockSetter modifies storage of raw resource data.
type MovieBlockSetter interface {
SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte)
DelResource(lang resource.Language, id resource.ID)
}
// MovieSetterService can be used to set movie data.
type MovieSetterService struct {
cp text.Codepage
}
// NewMovieSetterService returns a new instance.
func NewMovieSetterService(cp text.Codepage) MovieSetterService {
return MovieSetterService{cp: cp}
}
// Remove deletes any movie resource for given key.
func (service MovieSetterService) Remove(setter MovieBlockSetter, key resource.Key) {
setter.DelResource(key.Lang, key.ID)
}
// Set exports the given container.
func (service MovieSetterService) Set(setter MovieBlockSetter, key resource.Key, container movie.Container) {
buf := bytes.NewBuffer(nil)
_ = movie.Write(buf, container, service.cp)
setter.SetResourceBlocks(key.Lang, key.ID, [][]byte{buf.Bytes()})
}
@@ -0,0 +1,68 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/resource"
)
// MovieBlockGetter provides raw data of blocks.
type MovieBlockGetter interface {
ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte
}
// MovieViewerService provides read-only access to movie resources.
type MovieViewerService struct {
movieCache *movie.Cache
getter MovieBlockGetter
}
// NewMovieViewerService returns a new instance.
func NewMovieViewerService(movieCache *movie.Cache, getter MovieBlockGetter) MovieViewerService {
return MovieViewerService{
movieCache: movieCache,
getter: getter,
}
}
// Modified returns true if the identified text resource is marked as modified.
func (service MovieViewerService) Modified(key resource.Key) bool {
return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0
}
// SizeWarning returns true if the given movie is larger than the underlying archive would support.
func (service MovieViewerService) SizeWarning(key resource.Key) bool {
return service.movieCache.SizeWarning(key)
}
// Container returns the base container of the movie.
func (service MovieViewerService) Container(key resource.Key) (movie.Container, error) {
return service.movieCache.Container(key)
}
// Video returns the visual component associated with the given key.
func (service MovieViewerService) Video(key resource.Key) []movie.Scene {
currentValue, cacheErr := service.movieCache.Video(key)
if cacheErr != nil {
return nil
}
return currentValue
}
// Audio returns the audio data associated with the given key.
func (service MovieViewerService) Audio(key resource.Key) audio.L8 {
currentValue, cacheErr := service.movieCache.Audio(key)
if cacheErr != nil {
return audio.L8{}
}
return currentValue
}
// Subtitles returns the subtitles associated with the given key.
func (service MovieViewerService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList {
currentValue, cacheErr := service.movieCache.Subtitles(key, language)
if cacheErr != nil {
return movie.SubtitleList{}
}
return currentValue
}
@@ -0,0 +1,42 @@
package media
import (
"bytes"
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/audio/voc"
"github.com/inkyblackness/hacked/ss1/resource"
)
// SoundEffectBlockSetter modifies storage of raw resource data.
type SoundEffectBlockSetter interface {
SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte)
DelResource(lang resource.Language, id resource.ID)
}
// SoundEffectSetterService provides methods to change sound resources.
type SoundEffectSetterService struct{}
// NewSoundSetterService returns a new instance.
func NewSoundSetterService() SoundEffectSetterService {
return SoundEffectSetterService{}
}
// Remove deletes any audio resource for given key.
func (service SoundEffectSetterService) Remove(setter SoundEffectBlockSetter, key resource.Key) {
setter.DelResource(key.Lang, key.ID.Plus(key.Index))
}
// Clear resets the identified audio resource to a silent one-sample audio.
func (service SoundEffectSetterService) Clear(setter SoundEffectBlockSetter, key resource.Key) {
silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}}
service.Set(setter, key, silence)
}
// Set stores the given sound as the identified resource.
func (service SoundEffectSetterService) Set(setter SoundEffectBlockSetter, key resource.Key, data audio.L8) {
buf := bytes.NewBuffer(nil)
_ = voc.Save(buf, data.SampleRate, data.Samples)
blockData := [][]byte{buf.Bytes()}
setter.SetResourceBlocks(key.Lang, key.ID.Plus(key.Index), blockData)
}
@@ -0,0 +1,38 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/sound"
"github.com/inkyblackness/hacked/ss1/resource"
)
// SoundEffectBlockGetter provides raw data of blocks.
type SoundEffectBlockGetter interface {
ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte
}
// SoundEffectViewerService provides read-only access to audio resources.
type SoundEffectViewerService struct {
soundCache *sound.EffectCache
getter SoundEffectBlockGetter
}
// NewSoundViewerService returns a new instance.
func NewSoundViewerService(soundCache *sound.EffectCache, getter SoundEffectBlockGetter) SoundEffectViewerService {
return SoundEffectViewerService{
soundCache: soundCache,
getter: getter,
}
}
// Audio returns the audio data associated with the given key.
func (service SoundEffectViewerService) Audio(key resource.Key) audio.L8 {
sound, _ := service.soundCache.Audio(key)
return sound
}
// Modified returns true if the identified sound resource is marked as modified.
func (service SoundEffectViewerService) Modified(key resource.Key) bool {
data := service.getter.ModifiedBlock(key.Lang, key.ID.Plus(key.Index), 0)
return len(data) > 0
}
@@ -0,0 +1,59 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
// TextBlockSetter modifies storage of raw resource data.
type TextBlockSetter interface {
SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte)
SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte)
DelResource(lang resource.Language, id resource.ID)
}
// TextSetterService provides methods to change text resources.
type TextSetterService struct {
cp text.Codepage
}
// NewTextSetterService returns a new instance.
func NewTextSetterService(cp text.Codepage) TextSetterService {
return TextSetterService{
cp: cp,
}
}
// Remove deletes any text resource for given key.
func (service TextSetterService) Remove(setter TextBlockSetter, key resource.Key) {
info, _ := ids.Info(key.ID)
if info.List {
setter.SetResourceBlock(key.Lang, key.ID, key.Index, nil)
} else {
id := key.ID.Plus(key.Index)
setter.DelResource(key.Lang, id)
}
}
// Clear resets the identified text resource to an empty string.
func (service TextSetterService) Clear(setter TextBlockSetter, key resource.Key) {
service.Set(setter, key, "")
}
// Set stores the given text as the identified resource.
func (service TextSetterService) Set(setter TextBlockSetter, key resource.Key, value string) {
blockedValue := text.Blocked(value)
info, _ := ids.Info(key.ID)
if info.List {
newData := service.cp.Encode(blockedValue[0])
setter.SetResourceBlock(key.Lang, key.ID, key.Index, newData)
} else {
newData := make([][]byte, len(blockedValue))
for index, blockLine := range blockedValue {
newData[index] = service.cp.Encode(blockLine)
}
id := key.ID.Plus(key.Index)
setter.SetResourceBlocks(key.Lang, id, newData)
}
}
@@ -0,0 +1,54 @@
package media
import (
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
// TextBlockGetter provides raw data of blocks.
type TextBlockGetter interface {
ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte
ModifiedBlocks(lang resource.Language, id resource.ID) [][]byte
}
// TextViewerService provides read-only access to text resources.
type TextViewerService struct {
lineCache *text.Cache
pageCache *text.Cache
getter TextBlockGetter
}
// NewTextViewerService returns a new instance.
func NewTextViewerService(lineCache, pageCache *text.Cache, getter TextBlockGetter) TextViewerService {
return TextViewerService{
lineCache: lineCache,
pageCache: pageCache,
getter: getter,
}
}
// Text returns the text data associated with the given key.
func (service TextViewerService) Text(key resource.Key) string {
var cache *text.Cache
resourceInfo, existing := ids.Info(key.ID)
if !existing || resourceInfo.List {
cache = service.lineCache
} else {
cache = service.pageCache
}
currentValue, cacheErr := cache.Text(key)
if cacheErr != nil {
currentValue = ""
}
return currentValue
}
// Modified returns true if the identified text resource is marked as modified.
func (service TextViewerService) Modified(key resource.Key) bool {
info, _ := ids.Info(key.ID)
if info.List {
return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0
}
return len(service.getter.ModifiedBlocks(key.Lang, key.ID.Plus(key.Index))) > 0
}
+2
View File
@@ -0,0 +1,2 @@
// Package media contains low-level media accessors and modifier.
package media
@@ -0,0 +1,91 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// AugmentedTextService provides read/write functionality with undo capability.
type AugmentedTextService struct {
wrapped edit.AugmentedTextService
commander cmd.Commander
}
// NewAugmentedTextService returns a new instance of a service.
func NewAugmentedTextService(wrapped edit.AugmentedTextService, commander cmd.Commander) AugmentedTextService {
return AugmentedTextService{
wrapped: wrapped,
commander: commander,
}
}
// WithAudio returns true if the identified resource is one with an audio component.
func (service AugmentedTextService) WithAudio(key resource.Key) bool {
return service.wrapped.WithAudio(key)
}
// Text returns the textual value of the identified text resource.
func (service AugmentedTextService) Text(key resource.Key) string {
return service.wrapped.Text(key)
}
// RequestSetText queues the change to update the text.
func (service AugmentedTextService) RequestSetText(key resource.Key, value string, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.SetText(setter, key, value)
},
service.wrapped.RestoreTextFunc(key),
restoreFunc)
}
// Sound returns the audio value of the identified text resource.
// In case the text resource has no audio, an empty sound will be returned.
func (service AugmentedTextService) Sound(key resource.Key) audio.L8 {
return service.wrapped.Sound(key)
}
// RequestSetSound queues the change to update the sound.
func (service AugmentedTextService) RequestSetSound(key resource.Key, sound audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.SetSound(setter, key, sound)
},
service.wrapped.RestoreSoundFunc(key),
restoreFunc)
}
// RequestClear queues the change to set both the text and the sound empty.
func (service AugmentedTextService) RequestClear(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.Clear(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestRemove queues the change to remove both the text and the sound from the storage.
func (service AugmentedTextService) RequestRemove(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.Remove(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service AugmentedTextService) requestCommand(
forward func(modder edit.AugmentedTextBlockSetter),
reverse func(modder edit.AugmentedTextBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,25 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/world"
)
type command struct {
forward func(world.Modder)
reverse func(world.Modder)
restore func()
}
func (c command) Do(modder world.Modder) error {
return c.perform(modder, c.forward)
}
func (c command) Undo(modder world.Modder) error {
return c.perform(modder, c.reverse)
}
func (c command) perform(modder world.Modder, callback func(setter world.Modder)) error {
callback(modder)
c.restore()
return nil
}
@@ -0,0 +1,131 @@
package undoable
import (
"time"
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// MovieService provides read/write functionality with undo capability.
type MovieService struct {
wrapped edit.MovieService
commander cmd.Commander
}
// NewMovieService returns a new instance of a service.
func NewMovieService(wrapped edit.MovieService, commander cmd.Commander) MovieService {
return MovieService{
wrapped: wrapped,
commander: commander,
}
}
// SizeWarning returns true if the given movie is larger than the underlying archive would support.
func (service MovieService) SizeWarning(key resource.Key) bool {
return service.wrapped.SizeWarning(key)
}
// Video returns the video component of identified movie.
func (service MovieService) Video(key resource.Key) []movie.Scene {
return service.wrapped.Video(key)
}
// RequestMoveSceneEarlier queues to move the identified scene earlier.
func (service MovieService) RequestMoveSceneEarlier(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.MoveSceneEarlier(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestMoveSceneLater queues to move the identified scene later.
func (service MovieService) RequestMoveSceneLater(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.MoveSceneLater(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestAddScene queues to add the given scene at the end of the movie.
func (service MovieService) RequestAddScene(key resource.Key, scene movie.HighResScene, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.AddScene(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestRemoveScene queues to remove the identified scene.
func (service MovieService) RequestRemoveScene(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.RemoveScene(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestSetSceneFramesDisplayTime requests to set the display time for each frame.
func (service MovieService) RequestSetSceneFramesDisplayTime(key resource.Key,
scene int, displayTime time.Duration, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetSceneFramesDisplayTime(setter, key, scene, displayTime)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Audio returns the audio component of identified movie.
func (service MovieService) Audio(key resource.Key) audio.L8 {
return service.wrapped.Audio(key)
}
// RequestSetAudio queues the change to update the audio track.
func (service MovieService) RequestSetAudio(key resource.Key, soundData audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetAudio(setter, key, soundData)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Subtitles returns the subtitles associated with the given key.
func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList {
return service.wrapped.Subtitles(key, language)
}
// RequestSetSubtitles queues the change to update subtitles.
func (service MovieService) RequestSetSubtitles(key resource.Key,
language resource.Language, subtitles movie.SubtitleList, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetSubtitles(setter, key, language, subtitles)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service MovieService) requestCommand(
forward func(modder media.MovieBlockSetter),
reverse func(modder media.MovieBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,76 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// SoundEffectService provides read/write functionality with undo capability.
type SoundEffectService struct {
wrapped edit.SoundEffectService
commander cmd.Commander
}
// NewSoundEffectService returns a new instance of a service.
func NewSoundEffectService(wrapped edit.SoundEffectService, commander cmd.Commander) SoundEffectService {
return SoundEffectService{
wrapped: wrapped,
commander: commander,
}
}
// Modified returns true if the identified sound resource is marked as modified.
func (service SoundEffectService) Modified(key resource.Key) bool {
return service.wrapped.Modified(key)
}
// RequestRemove queues to erase the sound from the resources.
func (service SoundEffectService) RequestRemove(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.Remove(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestClear queues to reset the identified audio resource to a silent one-sample audio.
func (service SoundEffectService) RequestClear(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.Clear(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Audio returns the audio component of identified sound effect.
func (service SoundEffectService) Audio(key resource.Key) audio.L8 {
return service.wrapped.Audio(key)
}
// RequestSetAudio queues the change to update the audio.
func (service SoundEffectService) RequestSetAudio(key resource.Key, data audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.SetAudio(setter, key, data)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service SoundEffectService) requestCommand(
forward func(modder media.SoundEffectBlockSetter),
reverse func(modder media.SoundEffectBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,15 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
// Command describes an action that can be performed, undone, and redone.
type Command interface {
// Do performs the action.
// An error is returned if the action could not be performed.
Do(modder world.Modder) error
// Undo reverses its action to restore the environment to the previous state.
// An error is returned if the action could not be successfully undone. The
// environment may not be in the state as before in an error occurred.
Undo(modder world.Modder) error
}
@@ -0,0 +1,6 @@
package cmd
// Commander tries to execute the given command.
type Commander interface {
Queue(command Command)
}
@@ -0,0 +1,30 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
// List is a sequence of commands, which are executed as one step.
type List []Command
// Do performs the entries in the list in ascending order.
// If an entry returns an error, the iteration is aborted and that error is returned.
func (list List) Do(modder world.Modder) error {
for _, entry := range list {
err := entry.Do(modder)
if err != nil {
return err
}
}
return nil
}
// Undo performs the entries in the list in descending order.
// If an entry returns an error, the iteration is aborted and that error is returned.
func (list List) Undo(modder world.Modder) error {
for i := len(list) - 1; i >= 0; i-- {
err := list[i].Undo(modder)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,100 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
type stackEntry struct {
link *stackEntry
cmd Command
}
// Stack describes a list of commands. The stack allows to sequentially
// undo and redo stacked commands.
// It essentially stores two lists: a list of commands to undo, and
// another of commands to redo.
// Modifying stack functions will panic if they are called while already in use.
type Stack struct {
lockedBy string
undoList *stackEntry
redoList *stackEntry
}
// Perform executes the given command and puts it on the stack
// if the command was successful.
// This function also clears the list of commands to be redone.
func (stack *Stack) Perform(cmd Command, modder world.Modder) error {
stack.lock("Perform")
defer stack.unlock()
err := cmd.Do(modder)
if err != nil {
return err
}
stack.undoList = &stackEntry{link: stack.undoList, cmd: cmd}
stack.redoList = nil
return nil
}
// CanUndo returns true if there is at least one more command that can be undone.
func (stack *Stack) CanUndo() bool {
return stack.undoList != nil
}
// Undo attempts to undo the previous command on the list.
// If there is no further command to undo, nothing happens.
// An error is returned if the command failed. In this case, the stack is
// unchanged and a further attempt to undo will try the same command again.
func (stack *Stack) Undo(modder world.Modder) error {
stack.lock("Undo")
defer stack.unlock()
if stack.undoList == nil {
return nil
}
entry := stack.undoList
err := entry.cmd.Undo(modder)
if err != nil {
return err
}
stack.undoList = entry.link
entry.link = stack.redoList
stack.redoList = entry
return nil
}
// CanRedo returns true if there is at least one more command that can be redone.
func (stack *Stack) CanRedo() bool {
return stack.redoList != nil
}
// Redo attempts to perform the next command on the redo list.
// If there is no further command to redo, nothing happens.
// An error is returned if the command failed. In this case, the stack is
// unchanged and a further attempt to redo will try the same command again.
func (stack *Stack) Redo(modder world.Modder) error {
stack.lock("Redo")
defer stack.unlock()
if stack.redoList == nil {
return nil
}
entry := stack.redoList
err := entry.cmd.Do(modder)
if err != nil {
return err
}
stack.redoList = entry.link
entry.link = stack.undoList
stack.undoList = entry
return nil
}
func (stack *Stack) lock(by string) {
if stack.lockedBy != "" {
panic("Stack already in use by <" + stack.lockedBy + ">")
}
stack.lockedBy = by
}
func (stack *Stack) unlock() {
stack.lockedBy = ""
}
@@ -0,0 +1,348 @@
package cmd_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
type TestCommand struct {
name string
executed int
reverted int
pendingError error
task func()
}
func (cmd *TestCommand) Do(modder world.Modder) error {
cmd.executed++
return cmd.run()
}
func (cmd *TestCommand) Undo(modder world.Modder) error {
cmd.reverted++
return cmd.run()
}
func (cmd *TestCommand) run() (err error) {
if cmd.task != nil {
cmd.task()
}
err = cmd.pendingError
cmd.pendingError = nil
return
}
type StackSuite struct {
suite.Suite
stack cmd.Stack
commands map[string]*TestCommand
}
func TestStackSuite(t *testing.T) {
suite.Run(t, new(StackSuite))
}
func (suite *StackSuite) SetupTest() {
suite.stack = cmd.Stack{}
suite.commands = make(map[string]*TestCommand)
}
func (suite *StackSuite) TestNewStackCantDoAnything() {
suite.thenStackShouldNotSupportRedo()
suite.thenStackShouldNotSupportUndo()
}
func (suite *StackSuite) TestPerformExecutesCommand() {
suite.whenPerforming(suite.aCommand("cmd1"))
suite.thenCommandShouldHaveBeenExecuted("cmd1")
}
func (suite *StackSuite) TestPerformAllowsUndoIfSuccessful() {
suite.whenPerforming(suite.aCommand("cmd1"))
suite.thenStackShouldSupportUndo()
}
func (suite *StackSuite) TestPerformIgnoresCommandIfItFails() {
suite.whenPerforming(suite.aCommandReturningError())
suite.thenStackShouldNotSupportUndo()
}
func (suite *StackSuite) TestPerformReturnsErrorOfCommand() {
err := ss1.StringError("fail first time")
suite.thenPerformShouldReturnError(suite.aCommandReturning(err), err)
}
func (suite *StackSuite) TestUndoRevertsCommand() {
suite.givenCommandWasPerformed("cmd1")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd1")
}
func (suite *StackSuite) TestUndoRevertsCommandOnlyOnce() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 1)
}
func (suite *StackSuite) TestUndoRevertsCommandsInSequence() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd2")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd1")
}
func (suite *StackSuite) TestUndoLeavesStackUnchangedIfCommandFails() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.givenCommandWillFail("cmd2")
suite.givenUndoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd2", 2)
}
func (suite *StackSuite) TestUndoEnablesRedo() {
suite.givenCommandWasPerformed("cmd1")
suite.whenUndoing()
suite.thenStackShouldSupportRedo()
}
func (suite *StackSuite) TestRedoExecutesCommandAgain() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
}
func (suite *StackSuite) TestRedoExecutesCommandOnlyOnce() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenRedoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
}
func (suite *StackSuite) TestRedoExecutesCommandsInSequence() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.givenUndoWasCalledTimes(2)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd2", 2)
}
func (suite *StackSuite) TestRedoLeavesStackUnchangedIfCommandFails() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenCommandWillFail("cmd1")
suite.givenRedoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 3)
}
func (suite *StackSuite) TestRedoMakesCommandsUndoableAgain() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenRedoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 2)
}
func (suite *StackSuite) TestPerformDropsPendingRedoStack() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenPerforming(suite.aCommand("cmd2"))
suite.thenStackShouldNotSupportRedo()
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 1)
}
func (suite *StackSuite) TestPerformPanicsIfStackIsInUse() {
callPerform := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Perform(suite.aCommand(name+"-nested"), nil)
}, "Perform during function <"+name+"> should panic")
}
}
suite.assertPanics(callPerform)
}
func (suite *StackSuite) TestUndoPanicsIfStackIsInUse() {
callUndo := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Undo(nil)
}, "Undo during function <"+name+"> should panic")
}
}
suite.assertPanics(callUndo)
}
func (suite *StackSuite) TestRedoPanicsIfStackIsInUse() {
callRedo := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Undo(nil)
}, "Redo during function <"+name+"> should panic")
}
}
suite.assertPanics(callRedo)
}
func (suite *StackSuite) assertPanics(taskFor func(string) func()) {
cmd1 := suite.aCommandExecuting("cmd1", taskFor("Perform"))
suite.whenPerforming(cmd1)
suite.givenCommandWasPerformed("cmd2")
suite.givenCommandExecutes("cmd2", taskFor("Undo"))
suite.whenUndoing()
suite.givenCommandWasPerformed("cmd3")
suite.givenUndoWasCalledTimes(1)
suite.givenCommandExecutes("cmd3", taskFor("Redo"))
suite.whenRedoing()
}
func (suite *StackSuite) givenCommandWasPerformed(name string) {
suite.whenPerforming(suite.aCommand(name))
}
func (suite *StackSuite) givenUndoWasCalledTimes(times int) {
for i := 0; i < times; i++ {
_ = suite.stack.Undo(nil)
}
}
func (suite *StackSuite) givenRedoWasCalledTimes(times int) {
for i := 0; i < times; i++ {
_ = suite.stack.Redo(nil)
}
}
func (suite *StackSuite) givenCommandWillFail(name string) {
suite.pastCommand(name).pendingError = ss1.StringError("failing")
}
func (suite *StackSuite) givenCommandExecutes(name string, task func()) {
cmd := suite.pastCommand(name)
cmd.task = task
}
func (suite *StackSuite) whenUndoing() {
_ = suite.stack.Undo(nil)
}
func (suite *StackSuite) whenRedoing() {
_ = suite.stack.Redo(nil)
}
func (suite *StackSuite) whenPerforming(command cmd.Command) {
_ = suite.stack.Perform(command, nil)
}
func (suite *StackSuite) thenStackShouldSupportRedo() {
assert.True(suite.T(), suite.stack.CanRedo(), "Stack should be able to redo")
}
func (suite *StackSuite) thenStackShouldNotSupportRedo() {
assert.False(suite.T(), suite.stack.CanRedo(), "Stack should not be able to redo")
}
func (suite *StackSuite) thenStackShouldSupportUndo() {
assert.True(suite.T(), suite.stack.CanUndo(), "Stack should be able to undo")
}
func (suite *StackSuite) thenStackShouldNotSupportUndo() {
assert.False(suite.T(), suite.stack.CanUndo(), "Stack should not be able to undo")
}
func (suite *StackSuite) thenCommandShouldHaveBeenExecuted(name string) {
cmd := suite.pastCommand(name)
assert.True(suite.T(), cmd.executed > 0, "Command <"+name+"> should have been executed at least once")
}
func (suite *StackSuite) thenCommandShouldHaveBeenExecutedTimes(name string, expected int) {
cmd := suite.pastCommand(name)
assert.Equal(suite.T(), expected, cmd.executed)
}
func (suite *StackSuite) thenCommandShouldHaveBeenReverted(name string) {
cmd := suite.pastCommand(name)
assert.True(suite.T(), cmd.reverted > 0, "Command <"+name+"> should have been reverted at least once")
}
func (suite *StackSuite) thenCommandShouldHaveBeenRevertedTimes(name string, expected int) {
cmd := suite.pastCommand(name)
assert.Equal(suite.T(), expected, cmd.reverted)
}
func (suite *StackSuite) thenPerformShouldReturnError(cmd cmd.Command, expected error) {
result := suite.stack.Perform(cmd, nil)
assert.Equal(suite.T(), expected, result)
}
func (suite *StackSuite) aCommand(name string) cmd.Command {
cmd := &TestCommand{name: name}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) aCommandExecuting(name string, task func()) cmd.Command {
cmd := &TestCommand{name: name, task: task}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) aCommandReturningError() cmd.Command {
return suite.aCommandReturning(ss1.StringError("fail"))
}
func (suite *StackSuite) aCommandReturning(err error) cmd.Command {
name := "unnamed"
cmd := &TestCommand{name: name, pendingError: err}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) pastCommand(name string) *TestCommand {
cmd, found := suite.commands[name]
if !found {
require.True(suite.T(), found, "Command not found <"+name+"> - test is wrong")
}
return cmd
}
@@ -0,0 +1,157 @@
package cmd
import (
"fmt"
"github.com/inkyblackness/hacked/ss1/world"
)
// Task represents a modification on given modder.
type Task func(modder world.Modder) error
// TransactionError is returned from a transaction execution if a task failed.
type TransactionError struct {
// Name is that of the transaction, if it was set.
Name string
// Index identifies the list index of tasks.
Index int
// Nested is the error returned from the task. In case of nested commands, this may be another TransactionError.
Nested error
}
// Error returns the path of the transaction task, combined with the nested error text.
func (err TransactionError) Error() string {
return fmt.Sprintf("%s[%d]: %v", err.Name, err.Index, err.Nested)
}
// Unwrap returns the nested error, to support diving into nested errors.
func (err TransactionError) Unwrap() error {
return err.Nested
}
// Transaction is a named list of forward and reverse tasks.
// Transaction provides execution of the tasks as a Command.
type Transaction struct {
Name string
Forward []Task
Reverse []Task
}
// Do executes the forward tasks in series. The first task to return an error aborts the iteration.
// A TransactionError is returned in case of aborted iteration.
func (txn Transaction) Do(modder world.Modder) error {
return txn.perform(txn.Forward, modder)
}
// Undo executes the reverse tasks in series. The first task to return an error aborts the iteration.
// A TransactionError is returned in case of aborted iteration.
func (txn Transaction) Undo(modder world.Modder) error {
return txn.perform(txn.Reverse, modder)
}
func (txn Transaction) perform(tasks []Task, modder world.Modder) error {
for i, task := range tasks {
err := task(modder)
if err != nil {
return TransactionError{
Name: txn.Name,
Index: i,
Nested: err,
}
}
}
return nil
}
// TransactionModifier is a function to change a transaction while it is being built.
type TransactionModifier func(*Transaction) error
// Registry allows to build commands as transactions with modifier functions.
type Registry interface {
Commander
Register(modifier ...TransactionModifier) error
}
// TransactionBuilder provides a way to register a command at a commander with
// possibly nested actions.
type TransactionBuilder struct {
Commander Commander
active *Transaction
}
// Register creates a new transaction based on given modifier.
func (builder *TransactionBuilder) Register(modifier ...TransactionModifier) error {
previous := builder.active
current := &Transaction{}
builder.active = current
for _, mod := range modifier {
err := mod(current)
if err != nil {
return err
}
}
builder.active = previous
// invert reverse task for easier iteration
for left, right := 0, len(current.Reverse)-1; left < right; left, right = left+1, right-1 {
current.Reverse[left], current.Reverse[right] = current.Reverse[right], current.Reverse[left]
}
builder.Queue(current)
return nil
}
// Queue implements the Commander interface to add given command to a currently built transaction.
// If there is no outer transaction being built, then the command is immediately forwarded to the commander.
// Avoid building a loop between TransactionBuilder instances, as this will result in a stack overflow.
func (builder *TransactionBuilder) Queue(command Command) {
if builder.active == nil {
builder.Commander.Queue(command)
return
}
builder.active.Forward = append(builder.active.Forward, command.Do)
builder.active.Reverse = append(builder.active.Reverse, command.Undo)
}
// Named allows to name the current level of transaction.
func Named(name string) TransactionModifier {
return func(txn *Transaction) error {
txn.Name = name
return nil
}
}
// Forward creates a modifier for a task that is called when doing a command.
// If a nested command is added to the transaction after the forward function, then
// the nested task is performed after the given task.
// If a nested command is added to the transaction before the forward function, then
// the nested task is performed before the given task.
func Forward(task Task) TransactionModifier {
return func(txn *Transaction) error {
txn.Forward = append(txn.Forward, task)
return nil
}
}
// Reverse creates a modifier for a task that is called when undoing a command.
// If a nested command is added to the transaction after the reverse function, then
// the nested task is performed before the given task.
// If a nested command is added to the transaction before the reverse function, then
// the nested task is performed after the given task.
func Reverse(task Task) TransactionModifier {
return func(txn *Transaction) error {
txn.Reverse = append(txn.Reverse, task)
return nil
}
}
// Nested adds a further level to the currently registered transaction.
// Depending on the order of the modifier in the register, the corresponding tasks will be called
// in sequence. See other modifier.
func Nested(nested func() error) TransactionModifier {
return func(txn *Transaction) error {
return nested()
}
}
@@ -0,0 +1,135 @@
package cmd_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
func TestTransactionBuilder(t *testing.T) {
suite.Run(t, new(TransactionBuilderSuite))
}
type TransactionBuilderSuite struct {
suite.Suite
builder cmd.TransactionBuilder
command cmd.Command
result []string
err error
}
func (suite *TransactionBuilderSuite) SetupTest() {
suite.builder = cmd.TransactionBuilder{Commander: suite}
suite.command = nil
suite.result = nil
suite.err = nil
}
func (suite *TransactionBuilderSuite) TestTransactionCallsForwardTasksFromLeftToRight() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Forward(suite.aTask("fb")),
cmd.Reverse(suite.aTask("ra")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBe([]string{"fa", "fb"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsReverseTasksFromRightToLeft() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Reverse(suite.aTask("ra")),
cmd.Reverse(suite.aTask("rb")),
)
suite.whenCommandIsUndone()
suite.thenResultShouldBe([]string{"rb", "ra"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInForwardDirection() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Forward(suite.aTask("na")))
}),
cmd.Forward(suite.aTask("fb")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBe([]string{"fa", "na", "fb"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInReverseDirection() {
suite.givenRegisteredTransaction(
cmd.Reverse(suite.aTask("ra")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Reverse(suite.aTask("na")), cmd.Reverse(suite.aTask("nb")))
}),
cmd.Reverse(suite.aTask("rb")),
)
suite.whenCommandIsUndone()
suite.thenResultShouldBe([]string{"rb", "nb", "na", "ra"})
}
func (suite *TransactionBuilderSuite) TestTransactionReturnsErrorWithNamedPath() {
suite.givenRegisteredTransaction(
cmd.Named("main"),
cmd.Forward(suite.aTask("fa")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Named("nested"), cmd.Forward(suite.aTaskReturning(ss1.StringError("fail"))))
}),
cmd.Forward(suite.aTask("fb")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBeError("main[1]: nested[0]: fail")
}
func (suite *TransactionBuilderSuite) givenRegisteredTransaction(modifier ...cmd.TransactionModifier) {
err := suite.builder.Register(modifier...)
require.Nil(suite.T(), err, "no error expected registering transaction")
}
func (suite *TransactionBuilderSuite) whenCommandIsDone() {
suite.err = suite.command.Do(nil)
}
func (suite *TransactionBuilderSuite) whenCommandIsUndone() {
suite.err = suite.command.Undo(nil)
}
func (suite *TransactionBuilderSuite) thenResultShouldBe(expected []string) {
assert.Equal(suite.T(), expected, suite.result)
}
func (suite *TransactionBuilderSuite) thenResultShouldBeError(expected string) {
require.NotNil(suite.T(), suite.err, "no error returned")
var txnErr cmd.TransactionError
isTxnErr := errors.As(suite.err, &txnErr)
require.True(suite.T(), isTxnErr, "error is not a transaction error: %v", suite.err)
assert.Equal(suite.T(), expected, txnErr.Error())
assert.NotNil(suite.T(), txnErr.Nested, "no nested error set")
}
func (suite *TransactionBuilderSuite) Queue(command cmd.Command) {
require.Nil(suite.T(), suite.command, "Not expecting a second command")
suite.command = command
}
func (suite *TransactionBuilderSuite) aTask(text string) cmd.Task {
return func(modder world.Modder) error {
suite.result = append(suite.result, text)
return nil
}
}
func (suite *TransactionBuilderSuite) aTaskReturning(err error) cmd.Task {
return func(modder world.Modder) error {
return err
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package undoable contains edit extensions that provide an undo-mechanism.
package undoable