package animations import ( "bytes" "encoding/binary" "fmt" "image" "image/gif" "os" "path/filepath" "github.com/inkyblackness/imgui-go/v3" "github.com/inkyblackness/hacked/editor/external" "github.com/inkyblackness/hacked/editor/graphics" "github.com/inkyblackness/hacked/editor/render" "github.com/inkyblackness/hacked/ss1/content/bitmap" "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" "github.com/inkyblackness/hacked/ss1/resource" "github.com/inkyblackness/hacked/ss1/serial/rle" "github.com/inkyblackness/hacked/ss1/world" "github.com/inkyblackness/hacked/ss1/world/ids" "github.com/inkyblackness/hacked/ui/gui" ) type animationInfo struct { title string } var knownAnimationTypes = map[resource.ID]animationInfo{ ids.VideoMailAnimationsStart: {title: "Video Mail Parts"}, } var knownAnimationTypesOrder = []resource.ID{ ids.VideoMailAnimationsStart, } // View provides edit controls for animations. type View struct { mod *world.Mod imageCache *graphics.TextureCache paletteCache *graphics.PaletteCache animationCache *bitmap.AnimationCache modalStateMachine gui.ModalStateMachine guiScale float32 commander cmd.Commander model viewModel } // NewAnimationsView returns a new instance. func NewAnimationsView(mod *world.Mod, imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, animationCache *bitmap.AnimationCache, modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View { view := &View{ mod: mod, imageCache: imageCache, paletteCache: paletteCache, animationCache: animationCache, modalStateMachine: modalStateMachine, guiScale: guiScale, commander: commander, model: freshViewModel(), } return view } // WindowOpen returns the flag address, to be used with the main menu. func (view *View) WindowOpen() *bool { return &view.model.windowOpen } // Render renders the view. func (view *View) Render() { if view.model.restoreFocus { imgui.SetNextWindowFocus() view.model.restoreFocus = false view.model.windowOpen = true } if view.model.windowOpen { imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) if imgui.BeginV("Animations", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { view.renderContent() } imgui.End() } } func (view *View) renderContent() { if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) { imgui.PushItemWidth(-150 * view.guiScale) if imgui.BeginCombo("Animation Type", knownAnimationTypes[view.model.currentKey.ID].title) { for _, id := range knownAnimationTypesOrder { if imgui.SelectableV(knownAnimationTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { view.model.currentKey.ID = id view.model.currentKey.Index = 0 view.model.currentFrame = 0 } } imgui.EndCombo() } info, _ := ids.Info(view.model.currentKey.ID) if gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) { view.model.currentFrame = 0 } imgui.Separator() view.renderProperties() imgui.PopItemWidth() } imgui.EndChild() imgui.SameLine() if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) { anim, hasAnim, _ := view.currentAnimation() if hasAnim { frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, view.model.currentFrame) if view.cacheFrame(frameKey) { render.TextureImage("Frame", view.imageCache, frameKey, imgui.Vec2{X: float32(anim.Width) * view.guiScale, Y: float32(anim.Height) * view.guiScale}) if imgui.Button("Export") { view.requestExport() } } imgui.SameLine() if imgui.Button("Import") { view.requestImport() } } } imgui.EndChild() } func (view *View) cacheFrame(key resource.Key) bool { var err error var lastKey *resource.Key for index := 0; (index <= key.Index) && (err == nil); index++ { nextKey := resource.KeyOf(key.ID, key.Lang, index) _, err = view.imageCache.TextureReferenced(nextKey, lastKey) lastKey = &nextKey } return err == nil } func (view *View) renderProperties() { anim, hasAnim, _ := view.currentAnimation() widthString := "" heightString := "" lastFrame := 0 if hasAnim { widthString = fmt.Sprintf("%d", anim.Width) heightString = fmt.Sprintf("%d", anim.Height) for _, entry := range anim.Entries { lastFrame = int(entry.LastFrame) } } imgui.LabelText("Width", widthString) imgui.LabelText("Height", heightString) gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, lastFrame) } func (view *View) currentAnimation() (bitmap.Animation, bool, bool) { key := resource.KeyOf(view.model.currentKey.ID.Plus(view.model.currentKey.Index), resource.LangAny, 0) anim, err := view.animationCache.Animation(key) if err != nil { return anim, false, true } readOnly := len(view.mod.ModifiedBlocks(resource.LangAny, key.ID)) == 0 return anim, true, readOnly } func (view *View) requestImport() { info := "File must be an animated GIF file.\nIdeally, it matches the game palette 1:1,\nothers are mapped closest fitting." types := []external.TypeInfo{{Title: "Animation files (*.gif)", Extensions: []string{"gif"}}} var fileHandler func(string) fileHandler = func(filename string) { reader, err := os.Open(filename) if err != nil { external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true) return } defer func() { _ = reader.Close() }() data, err := gif.DecodeAll(reader) if err != nil { external.Import(view.modalStateMachine, "File not recognized as GIF.\n"+info, types, fileHandler, true) return } palette, err := view.paletteCache.Palette(0) if err != nil { external.Import(view.modalStateMachine, "Can not import image without having a palette loaded.\n"+info, types, fileHandler, true) return } anim := bitmap.Animation{ Width: int16(data.Config.Width), Height: int16(data.Config.Height), ResourceID: view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(-12), IntroFlag: 0, } var frames [][]byte rawPalette := palette.Palette() highestBitShift := func(value int16) (result byte) { if value != 0 { for (value >> result) != 1 { result++ } } return } var prevFrame []byte for index, img := range data.Image { if (img.Bounds().Max.X == data.Config.Width) && (img.Bounds().Max.Y == data.Config.Height) { bitmapper := bitmap.NewBitmapper(&rawPalette) bmp := bitmapper.Map(img) entry := bitmap.AnimationEntry{ FirstFrame: byte(index), LastFrame: byte(index), FrameTime: int16(data.Delay[index] * 10), } anim.Entries = append(anim.Entries, entry) bmp.Header.Type = bitmap.TypeCompressed8Bit bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width) bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height) bmp.Header.Area = [4]int16{0, 0, anim.Width, anim.Height} bmp.Header.Stride = uint16(bmp.Header.Width) buf := bytes.NewBuffer(nil) _ = binary.Write(buf, binary.LittleEndian, &bmp.Header) _ = rle.Compress(buf, bmp.Pixels, prevFrame) prevFrame = bmp.Pixels frames = append(frames, buf.Bytes()) } } view.requestSetAnimation(anim, frames) } external.Import(view.modalStateMachine, info, types, fileHandler, false) } func (view *View) requestExport() { anim, hasAnim, _ := view.currentAnimation() if hasAnim { filename := fmt.Sprintf("Anim%04X.gif", int(view.model.currentKey.ID.Plus(view.model.currentKey.Index))) view.exportTo(filename, anim) } } func (view *View) exportTo(filename string, anim bitmap.Animation) { info := "File to be written: " + filename var exportTo func(string) exportTo = func(dirname string) { writer, err := os.Create(filepath.Join(dirname, filename)) if err != nil { external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true) return } defer func() { _ = writer.Close() }() palTex, err := view.paletteCache.Palette(0) if err != nil { external.Export(view.modalStateMachine, "Could not create file. No palette loaded.\n"+info, exportTo, true) return } colorPalette := palTex.Palette().ColorPalette(false) data := gif.GIF{ Config: image.Config{ Width: int(anim.Width), Height: int(anim.Height), ColorModel: colorPalette, }, LoopCount: -1, } imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height) frameIndex := 0 for _, entry := range anim.Entries { for frameIndex <= int(entry.LastFrame) { frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, frameIndex) if !view.cacheFrame(frameKey) { external.Export(view.modalStateMachine, "Failed to cache frame.\n"+info, exportTo, true) return } frameTex, _ := view.imageCache.Texture(frameKey) frameImg := image.NewPaletted(imageRect, colorPalette) frameImg.Pix = frameTex.PixelData() data.Image = append(data.Image, frameImg) data.Delay = append(data.Delay, int(entry.FrameTime)/10) frameIndex++ } } err = gif.EncodeAll(writer, &data) if err != nil { external.Export(view.modalStateMachine, info, exportTo, true) return } } external.Export(view.modalStateMachine, info, exportTo, false) } func (view *View) requestSetAnimation(newAnim bitmap.Animation, newFrames [][]byte) { encodeAnim := func(anim bitmap.Animation) []byte { buf := bytes.NewBuffer(nil) _ = bitmap.WriteAnimation(buf, anim) return buf.Bytes() } command := setAnimationCommand{ model: &view.model, animationKey: view.model.currentKey, newAnimation: encodeAnim(newAnim), newFrames: newFrames, framesID: newAnim.ResourceID, } oldAnim, oldExisting, _ := view.currentAnimation() if oldExisting { command.oldAnimation = encodeAnim(oldAnim) } command.oldFrames = view.mod.ModifiedBlocks(view.model.currentKey.Lang, newAnim.ResourceID) view.commander.Queue(command) }