Adding modding folder with a hacked build.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package undoable contains edit extensions that provide an undo-mechanism.
|
||||
package undoable
|
||||
Reference in New Issue
Block a user