package doodle import ( "fmt" "math/rand" "git.kirsle.net/SketchyMaze/doodle/pkg/balance" "git.kirsle.net/SketchyMaze/doodle/pkg/branding" "git.kirsle.net/SketchyMaze/doodle/pkg/branding/builds" "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen" "git.kirsle.net/SketchyMaze/doodle/pkg/native" "git.kirsle.net/SketchyMaze/doodle/pkg/plus/dpp" "git.kirsle.net/SketchyMaze/doodle/pkg/savegame" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting" "git.kirsle.net/SketchyMaze/doodle/pkg/shmem" "git.kirsle.net/SketchyMaze/doodle/pkg/uix" "git.kirsle.net/SketchyMaze/doodle/pkg/updater" "git.kirsle.net/SketchyMaze/doodle/pkg/windows" "git.kirsle.net/go/render" "git.kirsle.net/go/render/event" "git.kirsle.net/go/ui" "git.kirsle.net/go/ui/style" ) // MainScene implements the main menu of Doodle. type MainScene struct { Supervisor *ui.Supervisor LevelFilename string // custom level filename to load in background // Background wallpaper canvas. scripting *scripting.Supervisor canvas *uix.Canvas // UI components. labelTitle *ui.Label labelSubtitle *ui.Label labelVersion *ui.Label labelHint *ui.Label frame *ui.Frame // Main button frame winRegister *ui.Window winSettings *ui.Window winLevelPacks *ui.Window winPlayLevel *ui.Window winOpenDrawing *ui.Window winAbout *ui.Window // Update check variables. updateButton *ui.Button updateInfo updater.VersionInfo // Lazy scroll variables. See LoopLazyScroll(). PauseLazyScroll bool // exported for dev console lazyScrollBounce bool lazyScrollTrajectory render.Point lazyScrollLastValue render.Point // Landscape mode: if the screen isn't tall enough to see the main // menu we redo the layout to be landscape friendly. NOTE: this only // happens one time, and does not re-adapt when the window is made // tall enough again. landscapeMode bool // Debug F3 overlay vars debLoadingViewport *string } /* MakePhotogenic tweaks some variables to make a screenshotable title screen. This function is designed to be called from the developer shell: $ d.Scene.MakePhotogenic(true) It automates the pausing of lazy scroll and hiding of UI elements except for just the title and version number. */ func (s *MainScene) MakePhotogenic(v bool) { if v { s.PauseLazyScroll = true s.ButtonFrame().Hide() s.LabelHint().Hide() } else { s.PauseLazyScroll = false s.ButtonFrame().Show() s.LabelHint().Show() } } // Name of the scene. func (s *MainScene) Name() string { return "Main" } // Setup the scene. func (s *MainScene) Setup(d *Doodle) error { s.debLoadingViewport = new(string) customDebugLabels = []debugLabel{ {"Chunks:", s.debLoadingViewport}, } s.Supervisor = ui.NewSupervisor() if err := s.SetupDemoLevel(d); err != nil { return err } // Main title label s.labelTitle = ui.NewLabel(ui.Label{ Text: branding.AppName, Font: balance.TitleScreenFont, }) s.labelTitle.Compute(d.Engine) // Subtitle/byline. s.labelSubtitle = ui.NewLabel(ui.Label{ Text: branding.Byline, Font: balance.TitleScreenSubtitleFont, }) s.labelSubtitle.Compute(d.Engine) // Version label. ver := ui.NewLabel(ui.Label{ Text: builds.Version, Font: balance.TitleScreenVersionFont, }) ver.Compute(d.Engine) s.labelVersion = ver // Arrow Keys hint label (scroll the demo level). s.labelHint = ui.NewLabel(ui.Label{ Text: "Hint: press the Arrow keys", Font: render.Text{ Size: 16, Color: render.Grey, Shadow: render.Purple, }, }) s.labelHint.Compute(d.Engine) // "Update Available" button. s.updateButton = ui.NewButton("Update Button", ui.NewLabel(ui.Label{ Text: "An update is available!", Font: render.Text{ FontFilename: balance.SansBoldFont, Size: 16, Color: render.Blue, Padding: 4, }, })) s.updateButton.Handle(ui.Click, func(ed ui.EventData) error { native.OpenURL(s.updateInfo.DownloadURL) return nil }) s.updateButton.Compute(d.Engine) s.updateButton.Hide() s.Supervisor.Add(s.updateButton) // Main UI button frame. frame := ui.NewFrame("frame") s.frame = frame var buttons = []struct { Name string If func() bool Func func() Style *style.Button }{ { Name: "Story Mode", Func: func() { if s.winLevelPacks == nil { s.winLevelPacks = windows.NewLevelPackWindow(windows.LevelPack{ Supervisor: s.Supervisor, Engine: d.Engine, OnPlayLevel: func(lp *levelpack.LevelPack, which levelpack.Level) { if err := d.PlayFromLevelpack(lp, which); err != nil { shmem.FlashError(err.Error()) } }, OnCloseWindow: func() { s.winLevelPacks.Destroy() s.winLevelPacks = nil }, }) } s.winLevelPacks.MoveTo(render.Point{ X: (d.width / 2) - (s.winLevelPacks.Size().W / 2), Y: (d.height / 2) - (s.winLevelPacks.Size().H / 2), }) s.winLevelPacks.Show() }, Style: &balance.ButtonBabyBlue, }, { Name: "Play a Level", Func: func() { s.showOpenDrawing(d, true) }, Style: &balance.ButtonBabyBlue, }, { Name: "New Drawing", Func: d.GotoNewMenu, Style: &balance.ButtonPink, }, { Name: "Edit Drawing", Func: func() { s.showOpenDrawing(d, false) }, Style: &balance.ButtonPink, }, { Name: "Settings", Func: func() { if s.winSettings == nil { s.winSettings = d.MakeSettingsWindow(s.Supervisor) } s.winSettings.Show() }, }, { Name: "Register", If: func() bool { return balance.DPP && !dpp.Driver.IsRegistered() }, Func: func() { if s.winRegister == nil { cfg := windows.License{ Supervisor: s.Supervisor, Engine: d.Engine, OnCancel: func() { s.winRegister.Hide() }, } cfg.OnLicensed = func() { // License status has changed, reload the window! if s.winRegister != nil { s.winRegister.Hide() } s.winRegister = windows.MakeLicenseWindow(d.width, d.height, cfg) } cfg.OnLicensed() } s.winRegister.Show() }, Style: &balance.ButtonPrimary, }, { Name: "About", If: func() bool { return !balance.DPP }, Func: func() { if s.winAbout == nil { s.winAbout = windows.NewAboutWindow(windows.About{ Supervisor: s.Supervisor, Engine: d.Engine, }) s.winAbout.Compute(d.Engine) s.winAbout.Supervise(s.Supervisor) // Center the window. s.winAbout.MoveTo(render.Point{ X: (d.width / 2) - (s.winAbout.Size().W / 2), Y: 60, }) } s.winAbout.Show() }, }, } for _, button := range buttons { if check := button.If; check != nil && !check() { continue } button := button btn := ui.NewButton(button.Name, ui.NewLabel(ui.Label{ Text: button.Name, Font: balance.StatusFont, })) btn.Handle(ui.Click, func(ed ui.EventData) error { button.Func() return nil }) if button.Style != nil { btn.SetStyle(button.Style) } s.Supervisor.Add(btn) frame.Pack(btn, ui.Pack{ Side: ui.N, PadY: 8, // Fill: true, FillX: true, }) } // Check for update in the background. go s.checkUpdate() // Migrate the savefile format to UUIDs. go func() { if err := savegame.Migrate(); err != nil { log.Error(err.Error()) } }() // Eager load the level in background, no time for load screen. go func() { if err := s.setupAsync(d); err != nil { log.Error("MainScene.setupAsync: %s", err) } }() // Trigger our "Window Resized" function so we can check if the // layout needs to be switched to landscape mode for mobile. s.Resized(d.width, d.height) return nil } // common function to show the "Open Drawing" window for the Play Level/Edit Drawing buttons. func (s *MainScene) showOpenDrawing(d *Doodle, forPlay bool) { // Find or create the relevant window. var window *ui.Window if forPlay { window = s.winPlayLevel if window == nil { window = windows.NewOpenDrawingWindow(windows.OpenDrawing{ Supervisor: s.Supervisor, Engine: shmem.CurrentRenderEngine, LevelsOnly: true, OnOpenDrawing: func(filename string) { d.PlayLevel(filename) }, OnCloseWindow: func() { s.winPlayLevel.Destroy() s.winPlayLevel = nil }, }) s.winPlayLevel = window } } else { window = s.winOpenDrawing if window == nil { window = windows.NewOpenDrawingWindow(windows.OpenDrawing{ Supervisor: s.Supervisor, Engine: shmem.CurrentRenderEngine, OnOpenDrawing: func(filename string) { d.EditFile(filename) }, OnCloseWindow: func() { s.winOpenDrawing.Destroy() s.winOpenDrawing = nil }, }) s.winOpenDrawing = window } } window.MoveTo(render.Point{ X: (d.width / 2) - (window.Size().W / 2), Y: (d.height / 2) - (window.Size().H / 2), }) window.Show() } // setupAsync runs background tasks from setup, e.g. eager load // chunks of the level for cache. func (s *MainScene) setupAsync(d *Doodle) error { loadscreen.PreloadAllChunkBitmaps(s.canvas.Chunker()) return nil } // checkUpdate checks for a version update and shows the button. func (s *MainScene) checkUpdate() { if shmem.OfflineMode { log.Info("OfflineMode: skip updates check") return } info, err := updater.Check() if err != nil { log.Error(err.Error()) return } if info.IsNewerVersionThan(branding.Version) { s.updateInfo = info s.updateButton.Show() } } // SetupDemoLevel configures the wallpaper behind the New screen, // which demos a title screen demo level. func (s *MainScene) SetupDemoLevel(d *Doodle) error { // Set up the background wallpaper canvas. s.canvas = uix.NewCanvas(100, false) s.canvas.Scrollable = true s.canvas.Resize(render.Rect{ W: d.width, H: d.height, }) s.scripting = scripting.NewSupervisor() s.canvas.SetScriptSupervisor(s.scripting) // Title screen level to load. Pick a random level. var ( levelName = balance.DemoLevelName[0] fromLevelPack = true lvl *level.Level ) if s.LevelFilename != "" { // User provided a custom level name, nix the demo levelpack. levelName = s.LevelFilename fromLevelPack = false } else if len(balance.DemoLevelName) > 1 { randIndex := rand.Intn(len(balance.DemoLevelName)) levelName = balance.DemoLevelName[randIndex] } // Get the level from the DemoLevelPack? if fromLevelPack { log.Debug("Initializing titlescreen from DemoLevelPack: %s", balance.DemoLevelPack) lp, err := levelpack.LoadFile(balance.DemoLevelPack) if err != nil { log.Error("Error loading DemoLevelPack(%s): %s", balance.DemoLevelPack, err) } else { log.Debug("Loading selected level from pack: %s", levelName) levelbin, err := lp.GetFile("levels/" + levelName) if err != nil { log.Error("Error getting level from DemoLevelpack(%s#%s): %s", balance.DemoLevelPack, levelName, err, ) } else { log.Debug("Parsing loaded level data (%d bytes)", len(levelbin)) lvl, err = level.FromJSON(levelName, levelbin) if err != nil { log.Error("DemoLevelPack FromJSON(%s): %s", levelName, err) lvl = nil } } } } // May be a user-provided level. if lvl == nil { if trylvl, err := level.LoadFile(levelName); err == nil { lvl = trylvl } else { log.Error("Error loading demo level %s: %s", balance.DemoLevelName, err) } } // If still no level, initialize a basic notebook background. if lvl != nil { s.canvas.LoadLevel(lvl) s.canvas.InstallActors(lvl.Actors) // Load all actor scripts. if err := s.scripting.InstallScripts(lvl); err != nil { log.Error("Error with title screen level scripts: %s", err) } // Run all actors scripts main function to start them off. if err := s.canvas.InstallScripts(); err != nil { log.Error("Error running actor main() functions: %s", err) } } else { // Create a basic notebook level. s.canvas.LoadLevel(&level.Level{ Chunker: level.NewChunker(100), Palette: level.NewPalette(), PageType: level.Bounded, MaxWidth: 42, MaxHeight: 42, Wallpaper: "notebook.png", }) } return nil } // Loop the editor scene. func (s *MainScene) Loop(d *Doodle, ev *event.State) error { s.Supervisor.Loop(ev) inside, outside := s.canvas.LoadUnloadMetrics() *s.debLoadingViewport = fmt.Sprintf("%d in %d out", inside, outside) if err := s.scripting.Loop(); err != nil { log.Error("MainScene.Loop: scripting.Loop: %s", err) } // Lazily scroll the canvas around, slowly. s.LoopLazyScroll() s.canvas.Loop(ev) if ev.WindowResized { s.Resized(d.width, d.height) } return nil } // Resized the app window. func (s *MainScene) Resized(width, height int) { log.Info("Resized to %dx%d", width, height) // If the height is not tall enough for the menu, switch to the horizontal layout. isLandscape := balance.IsShortWide(width, height) if isLandscape != s.landscapeMode { log.Info("Toggled LandscapeMode to: %+v", isLandscape) } s.landscapeMode = isLandscape s.canvas.Resize(render.Rect{ W: width, H: height, }) } // ButtonFrame returns the main button frame. func (s *MainScene) ButtonFrame() *ui.Frame { return s.frame } // LabelVersion returns the version widget. func (s *MainScene) LabelVersion() *ui.Label { return s.labelVersion } // LabelHint returns the hint widget. func (s *MainScene) LabelHint() *ui.Label { return s.labelHint } // Move things into position for the main menu. This function arranges // the Title, Subtitle, Buttons, etc. into screen relative positions every // tick. This function sets their 'default' values, but if the window is // not tall enough and needs the landscape orientation, positionMenuLandscape() // will override these defaults. func (s *MainScene) positionMenuPortrait(d *Doodle) { // App title label. s.labelTitle.MoveTo(render.Point{ X: (d.width / 2) - (s.labelTitle.Size().W / 2), Y: 120, }) // App subtitle label (byline). s.labelSubtitle.MoveTo(render.Point{ X: (d.width / 2) - (s.labelSubtitle.Size().W / 2), Y: s.labelTitle.Point().Y + s.labelTitle.Size().H + 8, }) // Version label s.labelVersion.MoveTo(render.Point{ X: (d.width) - (s.labelVersion.Size().W) - 20, Y: 20, }) // Hint label. s.labelHint.MoveTo(render.Point{ X: (d.width / 2) - (s.labelHint.Size().W / 2), Y: d.height - s.labelHint.Size().H - 32, }) // Update button. s.updateButton.MoveTo(render.Point{ X: 24, Y: d.height - s.updateButton.Size().H - 24, }) // Button frame. s.frame.MoveTo(render.Point{ X: (d.width / 2) - (s.frame.Size().W / 2), Y: 260, }) } func (s *MainScene) positionMenuLandscape(d *Doodle) { s.positionMenuPortrait(d) var ( col1 = render.Rect{ X: 0, Y: 0, W: d.width / 2, H: d.height, } col2 = render.Rect{ X: d.width, Y: 0, W: d.width - col1.W, H: d.height, } ) // Title and subtitle move to the left. s.labelTitle.MoveTo(render.Point{ X: (col1.W / 2) - (s.labelTitle.Size().W / 2), Y: s.labelTitle.Point().Y, }) s.labelSubtitle.MoveTo(render.Point{ X: (col1.W / 2) - (s.labelSubtitle.Size().W / 2), Y: s.labelTitle.Point().Y + s.labelTitle.Size().H + 8, }) // Button frame to the right. s.frame.MoveTo(render.Point{ X: (col2.X+col2.W)/2 - (s.frame.Size().W / 2), Y: (d.height / 2) - (s.frame.Size().H / 2), }) } // LoopLazyScroll gently scrolls the title screen demo level, called each Loop. func (s *MainScene) LoopLazyScroll() { if s.PauseLazyScroll { return } // The v1 basic sauce algorithm: // 1. We scroll diagonally downwards and rightwards. // 2. When we scroll downwards far enough, we change direction. // Make a zigzag pattern. // 3. When we reach the right bound of the level // OR some max number of px into an unbounded level: // enter a simple ball bouncing mode like a screensaver. var ( zigzagMaxHeight = 512 maxScrollX = zigzagMaxHeight * 2 lastScrollValue = s.lazyScrollLastValue currentScroll = s.canvas.Scroll ) // So we have two states: // - Zigzag state (default) // - Bounce state (when we hit a wall) if !s.lazyScrollBounce { // Zigzag state. s.lazyScrollTrajectory = render.Point{ X: -1, // down and right Y: -1, } // When we've gone far enough X, it's also far enough Y. if currentScroll.X < -zigzagMaxHeight { s.lazyScrollTrajectory.Y = 1 // go back up } // Have we gotten stuck in a corner? (ending the zigzag phase, for bounded levels) if currentScroll.X < 0 && (currentScroll == lastScrollValue) || currentScroll.X < -maxScrollX { log.Debug("LoopLazyScroll: Ending zigzag phase, enter bounce phase") s.lazyScrollBounce = true s.lazyScrollTrajectory = render.Point{ X: -1, Y: -1, } } } else { var ( // Bounded and bordered levels will naturally hit // an edge and stop scrolling bounceY = currentScroll.Y == lastScrollValue.Y bounceX = currentScroll.X == lastScrollValue.X worldsize = s.canvas.Chunker().WorldSize() viewport = s.canvas.Viewport() ) // In case of unbounded levels, set limits ourself. if !bounceX { if viewport.X < worldsize.X || viewport.X > worldsize.W { bounceX = true // Set the trajectory the right direction immediately. if viewport.X < worldsize.X { s.lazyScrollTrajectory.X = 1 } else { s.lazyScrollTrajectory.X = -1 } } } if !bounceY { if viewport.Y < worldsize.Y || viewport.Y > worldsize.H { bounceY = true // Set the trajectory the right direction immediately. if viewport.Y < worldsize.Y { s.lazyScrollTrajectory.Y = 1 } else { s.lazyScrollTrajectory.Y = -1 } } } // Lazy bounce algorithm. if bounceY { log.Debug("LoopLazyScroll: Hit a floor/ceiling") s.lazyScrollTrajectory.Y = -s.lazyScrollTrajectory.Y } if bounceX { log.Debug("LoopLazyScroll: Hit the side of the map!") s.lazyScrollTrajectory.X = -s.lazyScrollTrajectory.X } } // Check the scroll. s.lazyScrollLastValue = currentScroll s.canvas.ScrollBy(s.lazyScrollTrajectory) } // Draw the pixels on this frame. func (s *MainScene) Draw(d *Doodle) error { // Clear the canvas and fill it with white. d.Engine.Clear(render.White) s.canvas.Present(d.Engine, render.Origin) // Draw a sheen over the level for clarity. d.Engine.DrawBox(render.RGBA(255, 255, 254, 96), render.Rect{ X: 0, Y: 0, W: d.width, H: d.height, }) // Draw out bounding boxes. if DebugCollision { for _, actor := range s.canvas.Actors() { d.DrawCollisionBox(s.canvas, actor) } } // Arrange the main widgets by Portrait or Landscape mode. if s.landscapeMode { s.positionMenuLandscape(d) } else { s.positionMenuPortrait(d) } // App title label. s.labelTitle.Present(d.Engine, s.labelTitle.Point()) // App subtitle label (byline). s.labelSubtitle.Present(d.Engine, s.labelSubtitle.Point()) // Version label s.labelVersion.Present(d.Engine, s.labelVersion.Point()) // Hint label. s.labelHint.Present(d.Engine, s.labelHint.Point()) // Update button. s.updateButton.Present(d.Engine, s.updateButton.Point()) s.frame.Compute(d.Engine) s.frame.Present(d.Engine, s.frame.Point()) // Present supervised windows. s.Supervisor.Present(d.Engine) return nil } // Destroy the scene. func (s *MainScene) Destroy() error { log.Debug("MainScene.Destroy(): clean up the demo level canvas") s.canvas.Destroy() return nil }