489 lines
15 KiB
Go
489 lines
15 KiB
Go
package messages
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"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/editor/values"
|
|
"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/undoable/cmd"
|
|
"github.com/inkyblackness/hacked/ss1/resource"
|
|
"github.com/inkyblackness/hacked/ss1/world"
|
|
"github.com/inkyblackness/hacked/ss1/world/ids"
|
|
"github.com/inkyblackness/hacked/ui/gui"
|
|
)
|
|
|
|
type messageInfo struct {
|
|
title string
|
|
}
|
|
|
|
var knownMessageTypes = map[resource.ID]messageInfo{
|
|
ids.MailsStart: {title: "Mails"},
|
|
ids.LogsStart: {title: "Logs"},
|
|
ids.FragmentsStart: {title: "Fragments"},
|
|
}
|
|
var knownMessageTypesOrder = []resource.ID{ids.MailsStart, ids.LogsStart, ids.FragmentsStart}
|
|
|
|
var knownMailTypes = []string{
|
|
"Audio/Text",
|
|
"VMail: 'Shield' (0)",
|
|
"VMail: 'Grove' (1)",
|
|
"VMail: 'Bridge' (2)",
|
|
"VMail: 'Laser' (3, 4)",
|
|
"VMail: 'Status' (11)",
|
|
"VMail: 'Explode' (5..9)",
|
|
}
|
|
|
|
const videoMailDisplayBase = 256
|
|
|
|
// View provides edit controls for messages.
|
|
type View struct {
|
|
mod *world.Mod
|
|
messageCache *text.ElectronicMessageCache
|
|
cp text.Codepage
|
|
movieCache *movie.Cache
|
|
imageCache *graphics.TextureCache
|
|
|
|
modalStateMachine gui.ModalStateMachine
|
|
clipboard external.Clipboard
|
|
guiScale float32
|
|
commander cmd.Commander
|
|
|
|
model viewModel
|
|
}
|
|
|
|
// NewMessagesView returns a new instance.
|
|
func NewMessagesView(mod *world.Mod, messageCache *text.ElectronicMessageCache, cp text.Codepage,
|
|
movieCache *movie.Cache, imageCache *graphics.TextureCache,
|
|
modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard,
|
|
guiScale float32, commander cmd.Commander) *View {
|
|
view := &View{
|
|
mod: mod,
|
|
messageCache: messageCache,
|
|
cp: cp,
|
|
movieCache: movieCache,
|
|
imageCache: imageCache,
|
|
|
|
modalStateMachine: modalStateMachine,
|
|
clipboard: clipboard,
|
|
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: 640 * view.guiScale, Y: 480 * view.guiScale}, imgui.ConditionFirstUseEver)
|
|
if imgui.BeginV("Messages", view.WindowOpen(), imgui.WindowFlagsNoCollapse) {
|
|
view.renderContent()
|
|
}
|
|
imgui.End()
|
|
}
|
|
}
|
|
|
|
func (view *View) renderContent() {
|
|
if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: -200 * view.guiScale}, false, 0) {
|
|
imgui.PushItemWidth(-150 * view.guiScale)
|
|
if imgui.BeginCombo("Message Type", knownMessageTypes[view.model.currentKey.ID].title) {
|
|
for _, id := range knownMessageTypesOrder {
|
|
if imgui.SelectableV(knownMessageTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) {
|
|
view.model.currentKey.ID = id
|
|
view.model.currentKey.Index = 0
|
|
}
|
|
}
|
|
imgui.EndCombo()
|
|
}
|
|
info, _ := ids.Info(view.model.currentKey.ID)
|
|
index := view.model.currentKey.Index
|
|
if gui.StepSliderInt("Index", &index, 0, info.MaxCount-1) {
|
|
view.model.currentKey.Index = index
|
|
}
|
|
if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) {
|
|
languages := resource.Languages()
|
|
for _, lang := range languages {
|
|
if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) {
|
|
view.model.currentKey.Lang = lang
|
|
}
|
|
}
|
|
imgui.EndCombo()
|
|
}
|
|
|
|
message, msgReadOnly := view.currentMessage()
|
|
availableDisplays := view.availableDisplays()
|
|
|
|
if imgui.ButtonV("Clear All", imgui.Vec2{}) {
|
|
view.requestClearAll()
|
|
}
|
|
imgui.SameLine()
|
|
if !msgReadOnly {
|
|
if imgui.ButtonV("Remove All", imgui.Vec2{}) {
|
|
view.requestRemoveAll()
|
|
}
|
|
} else {
|
|
imgui.Text("(read-only)")
|
|
}
|
|
if view.hasAudio() {
|
|
imgui.PushID("audio")
|
|
imgui.Separator()
|
|
sound, soundReadOnly := view.currentSound()
|
|
if !sound.Empty() {
|
|
imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration()))
|
|
if imgui.Button("Export") {
|
|
view.requestExportAudio(sound)
|
|
}
|
|
if !soundReadOnly {
|
|
imgui.SameLine()
|
|
if imgui.Button("Remove") {
|
|
view.requestRemoveAudio()
|
|
}
|
|
}
|
|
imgui.SameLine()
|
|
} else {
|
|
imgui.LabelText("Audio", "(no sound)")
|
|
}
|
|
if imgui.Button("Import") {
|
|
view.requestImportAudio()
|
|
}
|
|
imgui.SameLine()
|
|
if imgui.Button("Clear") {
|
|
view.requestClearAudio()
|
|
}
|
|
imgui.PopID()
|
|
}
|
|
imgui.Separator()
|
|
|
|
textModes := map[bool]string{true: "Verbose", false: "Terse"}
|
|
if imgui.BeginCombo("Text Mode", textModes[view.model.showVerboseText]) {
|
|
for _, setting := range []bool{true, false} {
|
|
if imgui.SelectableV(textModes[setting], view.model.showVerboseText == setting, 0, imgui.Vec2{}) {
|
|
view.model.showVerboseText = setting
|
|
}
|
|
}
|
|
imgui.EndCombo()
|
|
}
|
|
view.renderText(false, "Title", message.Title, func(newValue string) {
|
|
view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Title = text.Blocked(newValue)[0] })
|
|
})
|
|
view.renderText(false, "Sender", message.Sender, func(newValue string) {
|
|
view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Sender = text.Blocked(newValue)[0] })
|
|
})
|
|
view.renderText(false, "Subject", message.Subject, func(newValue string) {
|
|
view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Subject = text.Blocked(newValue)[0] })
|
|
})
|
|
nextMessageIndex := values.NewUnifier()
|
|
nextMessageIndex.Add(message.NextMessage)
|
|
values.RenderUnifiedSliderInt(false, "Next Message Index", nextMessageIndex,
|
|
func(u values.Unifier) int { return u.Unified().(int) },
|
|
func(int) string { return "%d" },
|
|
-1, 255,
|
|
func(newValue int) {
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.NextMessage = newValue })
|
|
})
|
|
|
|
isInterrupt := values.NewUnifier()
|
|
isInterrupt.Add(message.IsInterrupt)
|
|
values.RenderUnifiedCheckboxCombo(false, "Is Interrupt", isInterrupt,
|
|
func(newValue bool) {
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.IsInterrupt = newValue })
|
|
})
|
|
|
|
colorIndex := values.NewUnifier()
|
|
colorIndex.Add(message.ColorIndex)
|
|
values.RenderUnifiedSliderInt(false, "Color Index", colorIndex,
|
|
func(u values.Unifier) int { return u.Unified().(int) },
|
|
func(int) string { return "%d" },
|
|
-1, 255,
|
|
func(newValue int) {
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.ColorIndex = newValue })
|
|
})
|
|
|
|
if view.model.currentKey.ID == ids.MailsStart {
|
|
mailType := message.LeftDisplay - videoMailDisplayBase + 1
|
|
selectedType := fmt.Sprintf("Unknown (0x%03X)", message.LeftDisplay)
|
|
if mailType < 0 {
|
|
mailType = 0
|
|
}
|
|
if mailType < len(knownMailTypes) {
|
|
selectedType = knownMailTypes[mailType]
|
|
}
|
|
if msgReadOnly {
|
|
imgui.LabelText("Mail Type", selectedType)
|
|
} else if imgui.BeginCombo("Mail Type", selectedType) {
|
|
for typeIndex, typeString := range knownMailTypes {
|
|
if imgui.SelectableV(typeString, mailType == typeIndex, 0, imgui.Vec2{}) {
|
|
newIndex := -1
|
|
if typeIndex > 0 {
|
|
newIndex = videoMailDisplayBase + (typeIndex - 1)
|
|
}
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newIndex })
|
|
}
|
|
}
|
|
imgui.EndCombo()
|
|
}
|
|
}
|
|
if message.LeftDisplay < videoMailDisplayBase {
|
|
view.renderDisplayGallery(false, "Left Display", message.LeftDisplay, availableDisplays,
|
|
func(newValue int) {
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newValue })
|
|
})
|
|
view.renderDisplayGallery(false, "Right Display", message.RightDisplay, availableDisplays,
|
|
func(newValue int) {
|
|
view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.RightDisplay = newValue })
|
|
})
|
|
}
|
|
|
|
imgui.PopItemWidth()
|
|
}
|
|
imgui.EndChild()
|
|
|
|
view.renderMFDs()
|
|
}
|
|
|
|
func (view *View) renderMFDs() {
|
|
message, _ := view.currentMessage()
|
|
|
|
view.renderSideImage("LeftMFD", message.LeftDisplay)
|
|
imgui.SameLineV(0, 0)
|
|
textToDisplay := message.VerboseText
|
|
if imgui.BeginChildV("Text", imgui.Vec2{X: -250 * view.guiScale, Y: 190 * view.guiScale}, true, 0) {
|
|
if !view.model.showVerboseText {
|
|
textToDisplay = message.TerseText
|
|
}
|
|
|
|
imgui.PushTextWrapPos()
|
|
if len(textToDisplay) == 0 {
|
|
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5})
|
|
imgui.Text("(empty)")
|
|
imgui.PopStyleColor()
|
|
} else {
|
|
imgui.Text(textToDisplay)
|
|
}
|
|
imgui.PopTextWrapPos()
|
|
}
|
|
imgui.EndChild()
|
|
view.clipboardPopup(false, "Text", textToDisplay, func(newValue string) {
|
|
view.requestTextChange(func(msg *text.ElectronicMessage) {
|
|
if view.model.showVerboseText {
|
|
msg.VerboseText = newValue
|
|
} else {
|
|
msg.TerseText = newValue
|
|
}
|
|
})
|
|
})
|
|
imgui.SameLineV(0, 0)
|
|
view.renderSideImage("RightMFD", message.RightDisplay)
|
|
}
|
|
|
|
func (view View) currentMessage() (msg text.ElectronicMessage, readOnly bool) {
|
|
msg = view.messageOf(view.model.currentKey)
|
|
readOnly = len(view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index))) == 0
|
|
return
|
|
}
|
|
|
|
func (view View) hasAudio() bool {
|
|
return (view.model.currentKey.ID == ids.MailsStart) || (view.model.currentKey.ID == ids.LogsStart)
|
|
}
|
|
|
|
func (view *View) currentSound() (audio.L8, bool) {
|
|
if view.hasAudio() {
|
|
key := view.model.currentKey
|
|
key.ID = key.ID.Plus(300)
|
|
sound, _ := view.movieCache.Audio(key)
|
|
readOnly := len(view.mod.ModifiedBlocks(key.Lang, key.ID.Plus(view.model.currentKey.Index))) == 0
|
|
return sound, readOnly
|
|
}
|
|
return audio.L8{}, true
|
|
}
|
|
|
|
func (view View) messageOf(key resource.Key) text.ElectronicMessage {
|
|
msg, cacheErr := view.messageCache.Message(key)
|
|
if cacheErr != nil {
|
|
msg = text.EmptyElectronicMessage()
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func (view *View) availableDisplays() int {
|
|
selector := view.mod.LocalizedResources(view.model.currentKey.Lang)
|
|
res, err := selector.Select(ids.MfdDataBitmaps)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return res.BlockCount()
|
|
}
|
|
|
|
func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) {
|
|
imgui.LabelText(label, value)
|
|
view.clipboardPopup(readOnly, label, value, changeCallback)
|
|
}
|
|
|
|
func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) {
|
|
if imgui.BeginPopupContextItemV(label+"-Popup", 1) {
|
|
if imgui.Selectable("Copy to Clipboard") {
|
|
view.clipboard.SetString(value)
|
|
}
|
|
if !readOnly && imgui.Selectable("Copy from Clipboard") {
|
|
newValue, err := view.clipboard.String()
|
|
if err == nil {
|
|
changeCallback(newValue)
|
|
}
|
|
}
|
|
imgui.EndPopup()
|
|
}
|
|
}
|
|
|
|
func (view *View) renderDisplayGallery(readOnly bool, label string, index, availableDisplays int,
|
|
changeCallback func(int)) {
|
|
unifier := values.NewUnifier()
|
|
unifier.Add(index)
|
|
values.RenderUnifiedSliderInt(readOnly, label, unifier,
|
|
func(u values.Unifier) int { return u.Unified().(int) },
|
|
func(int) string { return "%d" },
|
|
-1, availableDisplays-1,
|
|
changeCallback)
|
|
render.TextureSelector("###"+label+" Texture", -1, view.guiScale, availableDisplays,
|
|
index, view.imageCache,
|
|
func(index int) resource.Key {
|
|
return resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index)
|
|
},
|
|
func(index int) string { return fmt.Sprintf("%d", index) },
|
|
changeCallback)
|
|
}
|
|
|
|
func (view *View) renderSideImage(label string, index int) {
|
|
size := imgui.Vec2{X: 250 * view.guiScale, Y: 190 * view.guiScale}
|
|
imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: size.X, Y: -size.Y})
|
|
if imgui.BeginChildV(label, size, false, 0) {
|
|
if index >= 0 {
|
|
key := resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index)
|
|
render.TextureImage(label, view.imageCache, key, size)
|
|
}
|
|
}
|
|
imgui.PopStyleVar()
|
|
imgui.EndChild()
|
|
}
|
|
|
|
func (view *View) requestExportAudio(sound audio.L8) {
|
|
filename := fmt.Sprintf("%05d_%s.wav",
|
|
view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300).Value(),
|
|
view.model.currentKey.Lang.String())
|
|
|
|
external.ExportAudio(view.modalStateMachine, filename, sound)
|
|
}
|
|
|
|
func (view *View) requestRemoveAudio() {
|
|
view.requestAudioChange(nil)
|
|
}
|
|
|
|
func (view *View) requestImportAudio() {
|
|
external.ImportAudio(view.modalStateMachine, func(sound audio.L8) {
|
|
movieData := movie.ContainSoundData(sound)
|
|
view.requestAudioChange(movieData)
|
|
})
|
|
}
|
|
|
|
func (view *View) requestClearAudio() {
|
|
view.requestAudioChange(view.silence())
|
|
}
|
|
|
|
func (view View) silence() []byte {
|
|
silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}}
|
|
return movie.ContainSoundData(silence)
|
|
}
|
|
|
|
func (view *View) requestClearAll() {
|
|
view.requestWipe(text.EmptyElectronicMessage().Encode(view.cp), view.silence())
|
|
}
|
|
|
|
func (view *View) requestRemoveAll() {
|
|
view.requestWipe(nil, nil)
|
|
}
|
|
|
|
func (view *View) requestWipe(newTextData [][]byte, newAudioData []byte) {
|
|
textEntries := make(map[resource.Language]messageDataEntry)
|
|
audioEntries := make(map[resource.Language]messageDataEntry)
|
|
textID := view.model.currentKey.ID.Plus(view.model.currentKey.Index)
|
|
for lang := resource.Language(0); lang < resource.LanguageCount; lang++ {
|
|
textEntries[lang] = messageDataEntry{
|
|
oldData: view.mod.ModifiedBlocks(lang, textID),
|
|
newData: newTextData,
|
|
}
|
|
if view.hasAudio() {
|
|
audioEntries[lang] = messageDataEntry{
|
|
oldData: view.mod.ModifiedBlocks(lang, textID.Plus(300)),
|
|
newData: [][]byte{newAudioData},
|
|
}
|
|
}
|
|
}
|
|
view.requestSetMessageData(textEntries, audioEntries)
|
|
}
|
|
|
|
func (view *View) requestTextChange(modifier func(*text.ElectronicMessage)) {
|
|
msg := view.messageOf(view.model.currentKey)
|
|
entries := make(map[resource.Language]messageDataEntry)
|
|
modifier(&msg)
|
|
|
|
entries[view.model.currentKey.Lang] = messageDataEntry{
|
|
oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index)),
|
|
newData: msg.Encode(view.cp),
|
|
}
|
|
view.requestSetMessageData(entries, nil)
|
|
}
|
|
|
|
func (view *View) requestPropertyChange(modifier func(*text.ElectronicMessage)) {
|
|
entries := make(map[resource.Language]messageDataEntry)
|
|
for lang := resource.Language(0); lang < resource.LanguageCount; lang++ {
|
|
key := view.model.currentKey
|
|
key.Lang = lang
|
|
msg := view.messageOf(key)
|
|
modifier(&msg)
|
|
|
|
entries[lang] = messageDataEntry{
|
|
oldData: view.mod.ModifiedBlocks(lang, key.ID.Plus(key.Index)),
|
|
newData: msg.Encode(view.cp),
|
|
}
|
|
}
|
|
view.requestSetMessageData(entries, nil)
|
|
}
|
|
|
|
func (view *View) requestAudioChange(movieData []byte) {
|
|
entries := make(map[resource.Language]messageDataEntry)
|
|
|
|
entries[view.model.currentKey.Lang] = messageDataEntry{
|
|
oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300)),
|
|
newData: [][]byte{movieData},
|
|
}
|
|
view.requestSetMessageData(nil, entries)
|
|
}
|
|
|
|
func (view *View) requestSetMessageData(textEntries map[resource.Language]messageDataEntry, audioEntries map[resource.Language]messageDataEntry) {
|
|
command := setMessageDataCommand{
|
|
key: view.model.currentKey,
|
|
showVerboseText: view.model.showVerboseText,
|
|
model: &view.model,
|
|
textEntries: textEntries,
|
|
audioEntries: audioEntries,
|
|
}
|
|
view.commander.Queue(command)
|
|
}
|