1
1
mirror of https://github.com/OpenDiablo2/OpenDiablo2 synced 2024-06-12 10:40:42 +00:00
OpenDiablo2/d2app/app.go
David Carrell 3bdbd5c358
rely on App to know how to navigate between screens using a callback interface with explicit methods (#592)
remove unused struct vars that were only stored in order to pass to the next screens

consolidate create game code to single method

export ScreenMode consts and SetScreenMode method to allow app to create the correct screens

Co-authored-by: carrelda <carrelda@git>
2020-07-14 13:11:23 -04:00

642 lines
16 KiB
Go

// Package d2app contains the OpenDiablo2 application shell
package d2app
import (
"bytes"
"container/ring"
"errors"
"fmt"
"image"
"image/gif"
"image/png"
"log"
"os"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"sync"
"github.com/OpenDiablo2/OpenDiablo2/d2common"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2data"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2resource"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2config"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2gui"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2inventory"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2screen"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2ui"
"github.com/OpenDiablo2/OpenDiablo2/d2game/d2gamescreen"
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2client"
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2client/d2clientconnectiontype"
"github.com/OpenDiablo2/OpenDiablo2/d2script"
"github.com/pkg/profile"
"golang.org/x/image/colornames"
"gopkg.in/alecthomas/kingpin.v2"
)
// App represents the main application for the engine
type App struct {
lastTime float64
lastScreenAdvance float64
showFPS bool
timeScale float64
captureState captureState
capturePath string
captureFrames []*image.RGBA
gitBranch string
gitCommit string
inputManager d2interface.InputManager
terminal d2interface.Terminal
scriptEngine *d2script.ScriptEngine
audio d2interface.AudioProvider
renderer d2interface.Renderer
tAllocSamples *ring.Ring
}
type bindTerminalEntry struct {
name string
description string
action interface{}
}
const (
defaultFPS = 0.04 // 1/25
bytesToMegabyte = 1024 * 1024
nSamplesTAlloc = 100
debugPopN = 6
)
// Create creates a new instance of the application
func Create(gitBranch, gitCommit string,
inputManager d2interface.InputManager,
terminal d2interface.Terminal,
scriptEngine *d2script.ScriptEngine,
audio d2interface.AudioProvider,
renderer d2interface.Renderer) *App {
result := &App{
gitBranch: gitBranch,
gitCommit: gitCommit,
inputManager: inputManager,
terminal: terminal,
scriptEngine: scriptEngine,
audio: audio,
renderer: renderer,
tAllocSamples: createZeroedRing(nSamplesTAlloc),
}
return result
}
// Run executes the application and kicks off the entire game process
func (p *App) Run() error {
profileOption := kingpin.Flag("profile", "Profiles the program, one of (cpu, mem, block, goroutine, trace, thread, mutex)").String()
kingpin.Parse()
if len(*profileOption) > 0 {
profiler := enableProfiler(*profileOption)
if profiler != nil {
defer profiler.Stop()
}
}
windowTitle := fmt.Sprintf("OpenDiablo2 (%s)", p.gitBranch)
// If we fail to initialize, we will show the error screen
if err := p.initialize(); err != nil {
if gameErr := p.renderer.Run(updateInitError, 800, 600, windowTitle); gameErr != nil {
return gameErr
}
return err
}
p.ToMainMenu()
if p.gitBranch == "" {
p.gitBranch = "Local Build"
}
d2common.SetBuildInfo(p.gitBranch, p.gitCommit)
if err := p.renderer.Run(p.update, 800, 600, windowTitle); err != nil {
return err
}
return nil
}
func (p *App) initialize() error {
p.timeScale = 1.0
p.lastTime = d2common.Now()
p.lastScreenAdvance = p.lastTime
config := d2config.Config
d2resource.LanguageCode = config.Language
p.renderer.SetWindowIcon("d2logo.png")
p.terminal.BindLogger()
terminalActions := [...]bindTerminalEntry{
{"dumpheap", "dumps the heap to pprof/heap.pprof", p.dumpHeap},
{"fullscreen", "toggles fullscreen", p.toggleFullScreen},
{"capframe", "captures a still frame", p.captureFrame},
{"capgifstart", "captures an animation (start)", p.startAnimationCapture},
{"capgifstop", "captures an animation (stop)", p.stopAnimationCapture},
{"vsync", "toggles vsync", p.toggleVsync},
{"fps", "toggle fps counter", p.toggleFpsCounter},
{"timescale", "set scalar for elapsed time", p.setTimeScale},
{"quit", "exits the game", p.quitGame},
{"screen-gui", "enters the gui playground screen", p.enterGuiPlayground},
{"js", "eval JS scripts", p.evalJS},
}
for idx := range terminalActions {
action := &terminalActions[idx]
if err := p.terminal.BindAction(action.name, action.description, action.action); err != nil {
log.Fatal(err)
}
}
if err := d2asset.Initialize(p.renderer, p.terminal); err != nil {
return err
}
if err := d2gui.Initialize(p.inputManager); err != nil {
return err
}
p.audio.SetVolumes(config.BgmVolume, config.SfxVolume)
if err := p.loadDataDict(); err != nil {
return err
}
if err := p.loadStrings(); err != nil {
return err
}
d2inventory.LoadHeroObjects()
d2ui.Initialize(p.inputManager, p.audio)
return nil
}
func (p *App) loadStrings() error {
tablePaths := []string{
d2resource.PatchStringTable,
d2resource.ExpansionStringTable,
d2resource.StringTable,
}
for _, tablePath := range tablePaths {
data, err := d2asset.LoadFile(tablePath)
if err != nil {
return err
}
d2common.LoadTextDictionary(data)
}
return nil
}
func (p *App) loadDataDict() error {
entries := []struct {
path string
loader func(data []byte)
}{
{d2resource.LevelType, d2datadict.LoadLevelTypes},
{d2resource.LevelPreset, d2datadict.LoadLevelPresets},
{d2resource.LevelWarp, d2datadict.LoadLevelWarps},
{d2resource.ObjectType, d2datadict.LoadObjectTypes},
{d2resource.ObjectDetails, d2datadict.LoadObjects},
{d2resource.Weapons, d2datadict.LoadWeapons},
{d2resource.Armor, d2datadict.LoadArmors},
{d2resource.Misc, d2datadict.LoadMiscItems},
{d2resource.UniqueItems, d2datadict.LoadUniqueItems},
{d2resource.Missiles, d2datadict.LoadMissiles},
{d2resource.SoundSettings, d2datadict.LoadSounds},
{d2resource.AnimationData, d2data.LoadAnimationData},
{d2resource.MonStats, d2datadict.LoadMonStats},
{d2resource.MonStats2, d2datadict.LoadMonStats2},
{d2resource.MonPreset, d2datadict.LoadMonPresets},
{d2resource.MagicPrefix, d2datadict.LoadMagicPrefix},
{d2resource.MagicSuffix, d2datadict.LoadMagicSuffix},
{d2resource.ItemStatCost, d2datadict.LoadItemStatCosts},
{d2resource.CharStats, d2datadict.LoadCharStats},
{d2resource.Hireling, d2datadict.LoadHireling},
{d2resource.Experience, d2datadict.LoadExperienceBreakpoints},
{d2resource.Gems, d2datadict.LoadGems},
{d2resource.DifficultyLevels, d2datadict.LoadDifficultyLevels},
{d2resource.AutoMap, d2datadict.LoadAutoMaps},
{d2resource.LevelDetails, d2datadict.LoadLevelDetails},
{d2resource.LevelMaze, d2datadict.LoadLevelMazeDetails},
{d2resource.LevelSubstitutions, d2datadict.LoadLevelSubstitutions},
{d2resource.CubeRecipes, d2datadict.LoadCubeRecipes},
{d2resource.SuperUniques, d2datadict.LoadSuperUniques},
{d2resource.Inventory, d2datadict.LoadInventory},
{d2resource.Skills, d2datadict.LoadSkills},
{d2resource.Properties, d2datadict.LoadProperties},
}
d2datadict.InitObjectRecords()
for _, entry := range entries {
data, err := d2asset.LoadFile(entry.path)
if err != nil {
return err
}
entry.loader(data)
}
return nil
}
func (p *App) renderDebug(target d2interface.Surface) error {
if !p.showFPS {
return nil
}
vsyncEnabled := p.renderer.GetVSyncEnabled()
fps := p.renderer.CurrentFPS()
cx, cy := p.renderer.GetCursorPos()
target.PushTranslation(5, 565)
target.DrawText("vsync:" + strconv.FormatBool(vsyncEnabled) + "\nFPS:" + strconv.Itoa(int(fps)))
target.Pop()
var m runtime.MemStats
runtime.ReadMemStats(&m)
target.PushTranslation(680, 0)
target.DrawText("Alloc " + strconv.FormatInt(int64(m.Alloc)/bytesToMegabyte, 10))
target.PushTranslation(0, 16)
target.DrawText("TAlloc/s " + strconv.FormatFloat(p.allocRate(m.TotalAlloc, fps), 'f', 2, 64))
target.PushTranslation(0, 16)
target.DrawText("Pause " + strconv.FormatInt(int64(m.PauseTotalNs/bytesToMegabyte), 10))
target.PushTranslation(0, 16)
target.DrawText("HeapSys " + strconv.FormatInt(int64(m.HeapSys/bytesToMegabyte), 10))
target.PushTranslation(0, 16)
target.DrawText("NumGC " + strconv.FormatInt(int64(m.NumGC), 10))
target.PushTranslation(0, 16)
target.DrawText("Coords " + strconv.FormatInt(int64(cx), 10) + "," + strconv.FormatInt(int64(cy), 10))
target.PopN(debugPopN)
return nil
}
func (p *App) renderCapture(target d2interface.Surface) error {
cleanupCapture := func() {
p.captureState = captureStateNone
p.capturePath = ""
p.captureFrames = nil
}
switch p.captureState {
case captureStateFrame:
defer cleanupCapture()
fp, err := os.Create(p.capturePath)
if err != nil {
return err
}
defer func() {
if err := fp.Close(); err != nil {
log.Fatal(err)
}
}()
screenshot := target.Screenshot()
if err := png.Encode(fp, screenshot); err != nil {
return err
}
log.Printf("saved frame to %s", p.capturePath)
case captureStateGif:
screenshot := target.Screenshot()
p.captureFrames = append(p.captureFrames, screenshot)
case captureStateNone:
if len(p.captureFrames) > 0 {
defer cleanupCapture()
fp, err := os.Create(p.capturePath)
if err != nil {
return err
}
defer func() {
if err := fp.Close(); err != nil {
log.Fatal(err)
}
}()
var (
framesTotal = len(p.captureFrames)
framesPal = make([]*image.Paletted, framesTotal)
frameDelays = make([]int, framesTotal)
framesPerCPU = framesTotal / runtime.NumCPU()
)
var waitGroup sync.WaitGroup
for i := 0; i < framesTotal; i += framesPerCPU {
waitGroup.Add(1)
go func(start, end int) {
defer waitGroup.Done()
for j := start; j < end; j++ {
var buffer bytes.Buffer
if err := gif.Encode(&buffer, p.captureFrames[j], nil); err != nil {
panic(err)
}
framePal, err := gif.Decode(&buffer)
if err != nil {
panic(err)
}
framesPal[j] = framePal.(*image.Paletted)
frameDelays[j] = 5
}
}(i, d2common.MinInt(i+framesPerCPU, framesTotal))
}
waitGroup.Wait()
if err := gif.EncodeAll(fp, &gif.GIF{Image: framesPal, Delay: frameDelays}); err != nil {
return err
}
log.Printf("saved animation to %s", p.capturePath)
}
}
return nil
}
func (p *App) render(target d2interface.Surface) error {
if err := d2screen.Render(target); err != nil {
return err
}
d2ui.Render(target)
if err := d2gui.Render(target); err != nil {
return err
}
if err := p.renderDebug(target); err != nil {
return err
}
if err := p.renderCapture(target); err != nil {
return err
}
if err := p.terminal.Render(target); err != nil {
return err
}
return nil
}
func (p *App) advance(elapsed, current float64) error {
elapsedLastScreenAdvance := (current - p.lastScreenAdvance) * p.timeScale
if elapsedLastScreenAdvance > defaultFPS {
p.lastScreenAdvance = current
if err := d2screen.Advance(elapsedLastScreenAdvance); err != nil {
return err
}
}
d2ui.Advance(elapsed)
if err := p.inputManager.Advance(elapsed, current); err != nil {
return err
}
if err := d2gui.Advance(elapsed); err != nil {
return err
}
if err := p.terminal.Advance(elapsed); err != nil {
return err
}
return nil
}
func (p *App) update(target d2interface.Surface) error {
currentTime := d2common.Now()
elapsedTime := (currentTime - p.lastTime) * p.timeScale
p.lastTime = currentTime
if err := p.advance(elapsedTime, currentTime); err != nil {
return err
}
if err := p.render(target); err != nil {
return err
}
if target.GetDepth() > 0 {
return errors.New("detected surface stack leak")
}
return nil
}
func (p *App) allocRate(totalAlloc uint64, fps float64) float64 {
p.tAllocSamples.Value = totalAlloc
p.tAllocSamples = p.tAllocSamples.Next()
deltaAllocPerFrame := float64(totalAlloc-p.tAllocSamples.Value.(uint64)) / nSamplesTAlloc
return deltaAllocPerFrame * fps / bytesToMegabyte
}
func (p *App) dumpHeap() {
if err := os.Mkdir("./pprof/", 0750); err != nil {
log.Fatal(err)
}
fileOut, _ := os.Create("./pprof/heap.pprof")
if err := pprof.WriteHeapProfile(fileOut); err != nil {
log.Fatal(err)
}
if err := fileOut.Close(); err != nil {
log.Fatal(err)
}
}
func (p *App) evalJS(code string) {
val, err := p.scriptEngine.Eval(code)
if err != nil {
p.terminal.OutputErrorf("%s", err)
return
}
log.Printf("%s", val)
}
func (p *App) toggleFullScreen() {
fullscreen := !p.renderer.IsFullScreen()
p.renderer.SetFullScreen(fullscreen)
p.terminal.OutputInfof("fullscreen is now: %v", fullscreen)
}
func (p *App) captureFrame(path string) {
p.captureState = captureStateFrame
p.capturePath = path
p.captureFrames = nil
}
func (p *App) startAnimationCapture(path string) {
p.captureState = captureStateGif
p.capturePath = path
p.captureFrames = nil
}
func (p *App) stopAnimationCapture() {
p.captureState = captureStateNone
}
func (p *App) toggleVsync() {
vsync := !p.renderer.GetVSyncEnabled()
p.renderer.SetVSyncEnabled(vsync)
p.terminal.OutputInfof("vsync is now: %v", vsync)
}
func (p *App) toggleFpsCounter() {
p.showFPS = !p.showFPS
p.terminal.OutputInfof("fps counter is now: %v", p.showFPS)
}
func (p *App) setTimeScale(timeScale float64) {
if timeScale <= 0 {
p.terminal.OutputErrorf("invalid time scale value")
} else {
p.terminal.OutputInfof("timescale changed from %f to %f", p.timeScale, timeScale)
p.timeScale = timeScale
}
}
func (p *App) quitGame() {
os.Exit(0)
}
func (p *App) enterGuiPlayground() {
d2screen.SetNextScreen(d2gamescreen.CreateGuiTestMain(p.renderer))
}
func createZeroedRing(n int) *ring.Ring {
r := ring.New(n)
for i := 0; i < n; i++ {
r.Value = uint64(0)
r = r.Next()
}
return r
}
func enableProfiler(profileOption string) interface{ Stop() } {
var options []func(*profile.Profile)
switch strings.ToLower(strings.Trim(profileOption, " ")) {
case "cpu":
log.Printf("CPU profiling is enabled.")
options = append(options, profile.CPUProfile)
case "mem":
log.Printf("Memory profiling is enabled.")
options = append(options, profile.MemProfile)
case "block":
log.Printf("Block profiling is enabled.")
options = append(options, profile.BlockProfile)
case "goroutine":
log.Printf("Goroutine profiling is enabled.")
options = append(options, profile.GoroutineProfile)
case "trace":
log.Printf("Trace profiling is enabled.")
options = append(options, profile.TraceProfile)
case "thread":
log.Printf("Thread creation profiling is enabled.")
options = append(options, profile.ThreadcreationProfile)
case "mutex":
log.Printf("Mutex profiling is enabled.")
options = append(options, profile.MutexProfile)
}
options = append(options, profile.ProfilePath("./pprof/"))
if len(options) > 1 {
return profile.Start(options...)
}
return nil
}
func updateInitError(target d2interface.Surface) error {
_ = target.Clear(colornames.Darkred)
width, height := target.GetSize()
target.PushTranslation(width/5, height/2)
target.DrawText(`Could not find the MPQ files in the directory:
%s\nPlease put the files and re-run the game.`, d2config.Config.MpqPath)
return nil
}
func (a *App) ToMainMenu() {
mainMenu := d2gamescreen.CreateMainMenu(a, a.renderer, a.inputManager, a.audio)
mainMenu.SetScreenMode(d2gamescreen.ScreenModeMainMenu)
d2screen.SetNextScreen(mainMenu)
}
func (a *App) ToSelectHero(connType d2clientconnectiontype.ClientConnectionType, host string) {
selectHero := d2gamescreen.CreateSelectHeroClass(a, a.renderer, a.audio, connType, host)
d2screen.SetNextScreen(selectHero)
}
func (a *App) ToCreateGame(filePath string, connType d2clientconnectiontype.ClientConnectionType, host string) {
gameClient, _ := d2client.Create(connType, a.scriptEngine)
if err := gameClient.Open(host, filePath); err != nil {
// TODO an error screen should be shown in this case
fmt.Printf("can not connect to the host: %s", host)
}
d2screen.SetNextScreen(d2gamescreen.CreateGame(a, a.renderer, a.inputManager, a.audio, gameClient, a.terminal))
}
func (a *App) ToCharacterSelect(connType d2clientconnectiontype.ClientConnectionType, connHost string) {
characterSelect := d2gamescreen.CreateCharacterSelect(a, a.renderer, a.inputManager, a.audio, connType, connHost)
d2screen.SetNextScreen(characterSelect)
}
func (a *App) ToMapEngineTest(region int, level int) {
met := d2gamescreen.CreateMapEngineTest(0, 1, a.terminal, a.renderer, a.inputManager)
d2screen.SetNextScreen(met)
}
func (a *App) ToCredits() {
d2screen.SetNextScreen(d2gamescreen.CreateCredits(a, a.renderer))
}