From af6b8625d621ccbf53cee711a7dca7019cab8377 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 26 Mar 2022 13:55:06 -0700 Subject: [PATCH] Flood Tool, Survival Mode for Azulian Tag New features: * Flood Tool for the editor. It replaces pixels of one color with another, contiguously. Has limits on how far from the original pixel it will color, to avoid infinite loops in case the user clicked on wide open void. The limit when clicking an existing color is 1200px or only a 600px limit if clicking into the void. * Cheat code: 'master key' to play locked Story Mode levels. Level GameRules feature added: * A new tab in the Level Properties dialog * Difficulty has been moved to this tab * Survival Mode: for silver high score, longest time alive is better than fastest time, for Azulian Tag maps. Gold high score is still based on fastest time - find the hidden level exit without dying! Tweaks to the Azulians' jump heights: * Blue Azulian: 12 -> 14 * Red Azulian: 14 -> 18 * White Azulian: 16 -> 20 Bugs fixed: * When editing your Palette to rename a color or add a new color, it wasn't possible to draw with that color until the editor was completely unloaded and reloaded; this is now fixed. * Minor bugfix in Difficulty.String() for Peaceful (-1) difficulty to avoid a negative array index. * Try and prevent user giving the same name to multiple swatches on their palette. Replacing the whole palette can let duplication through still. --- assets/sprites/flood-tool.png | Bin 0 -> 5840 bytes dev-assets/doodads/azulian/azulian.js | 4 +- pkg/balance/cheats.go | 6 ++ pkg/balance/feature_flags.go | 13 ++- pkg/balance/numbers.go | 4 + pkg/cheats.go | 8 ++ pkg/drawtool/tools.go | 2 + pkg/editor_ui_menubar.go | 4 +- pkg/editor_ui_popups.go | 4 + pkg/editor_ui_toolbar.go | 48 +++++---- pkg/enum/enum.go | 4 +- pkg/gamepad/gamepad.go | 2 +- pkg/keybind/keybind.go | 10 ++ pkg/level/palette.go | 8 ++ pkg/level/types.go | 13 ++- pkg/play_scene.go | 2 +- pkg/savegame/savegame.go | 19 +++- pkg/uix/canvas_editable.go | 123 ++++++++++++++++++---- pkg/uix/scripting.go | 2 +- pkg/windows/add_edit_level.go | 146 +++++++++++++++++++------- pkg/windows/levelpack_open.go | 2 +- pkg/windows/palette_editor.go | 55 +++++----- 22 files changed, 354 insertions(+), 125 deletions(-) create mode 100644 assets/sprites/flood-tool.png diff --git a/assets/sprites/flood-tool.png b/assets/sprites/flood-tool.png new file mode 100644 index 0000000000000000000000000000000000000000..0a44c89d5704ff13ed44596c32cc654913c6e7bd GIT binary patch literal 5840 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 literal 0 HcmV?d00001 diff --git a/dev-assets/doodads/azulian/azulian.js b/dev-assets/doodads/azulian/azulian.js index 5b580ce..2c77b6d 100644 --- a/dev-assets/doodads/azulian/azulian.js +++ b/dev-assets/doodads/azulian/azulian.js @@ -4,7 +4,7 @@ const color = Self.GetTag("color"); var playerSpeed = color === 'blue' ? 2 : 4, aggroX = 250, // X/Y distance sensitivity from player aggroY = color === 'blue' ? 100 : 200, - jumpSpeed = color === 'blue' ? 12 : 14, + jumpSpeed = color === 'blue' ? 14 : 18, animating = false, direction = "right", lastDirection = "right"; @@ -14,7 +14,7 @@ if (color === 'white') { aggroX = 1000; aggroY = 400; playerSpeed = 8; - jumpSpeed = 16; + jumpSpeed = 20; } function setupAnimations(color) { diff --git a/pkg/balance/cheats.go b/pkg/balance/cheats.go index ca7be9a..eadeef2 100644 --- a/pkg/balance/cheats.go +++ b/pkg/balance/cheats.go @@ -32,4 +32,10 @@ var ( CheatPlayAsAnvil = "megaton weight" CheatGodMode = "god mode" CheatDebugLoadScreen = "test load screen" + CheatUnlockLevels = "master key" +) + +// Global cheat boolean states. +var ( + CheatEnabledUnlockLevels bool ) diff --git a/pkg/balance/feature_flags.go b/pkg/balance/feature_flags.go index e00fc5b..f1a8510 100644 --- a/pkg/balance/feature_flags.go +++ b/pkg/balance/feature_flags.go @@ -4,8 +4,7 @@ package balance var Feature = feature{ ///////// // Experimental features that are off by default - Zoom: false, // enable the zoom in/out feature (very buggy rn) - ChangePalette: false, // reset your palette after level creation to a diff preset + ViewportWindow: false, // Open new viewport into your level ///////// // Fully activated features @@ -15,12 +14,17 @@ var Feature = feature{ // Allow embedded doodads in levels. EmbeddableDoodads: true, + + // Enable the zoom in/out feature (kinda buggy still) + Zoom: true, + + // Reassign an existing level's palette to a different builtin. + ChangePalette: true, } // FeaturesOn turns on all feature flags, from CLI --experimental option. func FeaturesOn() { - Feature.Zoom = true - Feature.ChangePalette = true + Feature.ViewportWindow = true } type feature struct { @@ -28,4 +32,5 @@ type feature struct { CustomWallpaper bool ChangePalette bool EmbeddableDoodads bool + ViewportWindow bool } diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index 25c441a..cfbbead 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -101,6 +101,10 @@ var ( // GameController thresholds. GameControllerMouseMoveMax float64 = 20 // Max pixels per tick to simulate mouse movement. GameControllerScrollMin float64 = 0.3 // Minimum threshold for a right-stick scroll event. + + // Limits on the Flood Fill tool so it doesn't run away on us. + FloodToolVoidLimit = 600 // If clicking the void, +- 1000 px limit + FloodToolLimit = 1200 // If clicking a valid color on the level ) // Edit Mode Values diff --git a/pkg/cheats.go b/pkg/cheats.go index f00eff0..efdce7c 100644 --- a/pkg/cheats.go +++ b/pkg/cheats.go @@ -161,6 +161,14 @@ func (c Command) cheatCommand(d *Doodle) bool { loadscreen.Hide() }() + case balance.CheatUnlockLevels: + balance.CheatEnabledUnlockLevels = !balance.CheatEnabledUnlockLevels + if balance.CheatEnabledUnlockLevels { + d.Flash("All locked Story Mode levels can now be played.") + } else { + d.Flash("All locked Story Mode levels are again locked.") + } + default: return false } diff --git a/pkg/drawtool/tools.go b/pkg/drawtool/tools.go index 83acd49..4e5d0da 100644 --- a/pkg/drawtool/tools.go +++ b/pkg/drawtool/tools.go @@ -14,6 +14,7 @@ const ( EraserTool PanTool TextTool + FloodTool ) var toolNames = []string{ @@ -26,6 +27,7 @@ var toolNames = []string{ "Eraser", "PanTool", "TextTool", + "FloodTool", } func (t Tool) String() string { diff --git a/pkg/editor_ui_menubar.go b/pkg/editor_ui_menubar.go index 8d0c2a5..fd87bbc 100644 --- a/pkg/editor_ui_menubar.go +++ b/pkg/editor_ui_menubar.go @@ -5,13 +5,13 @@ package doodle // The rest of it is controlled in editor_ui.go import ( + "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/level/giant_screenshot" "git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/native" - "git.kirsle.net/apps/doodle/pkg/usercfg" "git.kirsle.net/apps/doodle/pkg/userdir" "git.kirsle.net/apps/doodle/pkg/windows" "git.kirsle.net/go/render" @@ -137,7 +137,7 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { native.OpenLocalURL(userdir.ScreenshotDirectory) }) - if usercfg.Current.EnableFeatures { + if balance.Feature.ViewportWindow { levelMenu.AddSeparator() levelMenu.AddItemAccel("New viewport", "v", func() { pip := windows.MakePiPWindow(d.width, d.height, windows.PiP{ diff --git a/pkg/editor_ui_popups.go b/pkg/editor_ui_popups.go index f5d935e..9744ba2 100644 --- a/pkg/editor_ui_popups.go +++ b/pkg/editor_ui_popups.go @@ -298,6 +298,10 @@ func (u *EditorUI) SetupPopups(d *Doodle) { u.Scene.Doodad.Layers[u.Scene.ActiveLayer].Chunker.Redraw() } + // Flush the palette cache in case swatches got renamed, + // so it rebuilds the "color by name" map from scratch. + pal.FlushCaches() + // Reload the palette frame to reflect the changed data. u.Palette.Hide() u.Palette = u.SetupPalette(d) diff --git a/pkg/editor_ui_toolbar.go b/pkg/editor_ui_toolbar.go index 0dc3ab2..550ce8b 100644 --- a/pkg/editor_ui_toolbar.go +++ b/pkg/editor_ui_toolbar.go @@ -154,6 +154,35 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame { }, }, + { + Value: drawtool.FloodTool.String(), + Icon: "assets/sprites/flood-tool.png", + Tooltip: "Flood Tool", + Click: func() { + u.Canvas.Tool = drawtool.FloodTool + d.Flash("Flood Tool selected.") + }, + }, + + { + Value: drawtool.EraserTool.String(), + Icon: "assets/sprites/eraser-tool.png", + Tooltip: "Eraser Tool", + Style: &balance.ButtonLightRed, + Click: func() { + u.Canvas.Tool = drawtool.EraserTool + + // Set the brush size within range for the eraser. + if u.Canvas.BrushSize < balance.DefaultEraserBrushSize { + u.Canvas.BrushSize = balance.DefaultEraserBrushSize + } else if u.Canvas.BrushSize > balance.MaxEraserBrushSize { + u.Canvas.BrushSize = balance.MaxEraserBrushSize + } + + d.Flash("Eraser Tool selected.") + }, + }, + { Value: drawtool.ActorTool.String(), Icon: "assets/sprites/actor-tool.png", @@ -178,25 +207,6 @@ func (u *EditorUI) SetupToolbar(d *Doodle) *ui.Frame { d.Flash("Link Tool selected. Click a doodad in your level to link it to another.") }, }, - - { - Value: drawtool.EraserTool.String(), - Icon: "assets/sprites/eraser-tool.png", - Tooltip: "Eraser Tool", - Style: &balance.ButtonLightRed, - Click: func() { - u.Canvas.Tool = drawtool.EraserTool - - // Set the brush size within range for the eraser. - if u.Canvas.BrushSize < balance.DefaultEraserBrushSize { - u.Canvas.BrushSize = balance.DefaultEraserBrushSize - } else if u.Canvas.BrushSize > balance.MaxEraserBrushSize { - u.Canvas.BrushSize = balance.MaxEraserBrushSize - } - - d.Flash("Eraser Tool selected.") - }, - }, } // Arrange the buttons 2x2. diff --git a/pkg/enum/enum.go b/pkg/enum/enum.go index ece7391..79e09a0 100644 --- a/pkg/enum/enum.go +++ b/pkg/enum/enum.go @@ -38,8 +38,8 @@ const ( func (d Difficulty) String() string { return []string{ + "Peaceful", "Normal", "Hard", - "Peaceful", - }[d] + }[d+1] } diff --git a/pkg/gamepad/gamepad.go b/pkg/gamepad/gamepad.go index a84f7b9..0e2c492 100644 --- a/pkg/gamepad/gamepad.go +++ b/pkg/gamepad/gamepad.go @@ -125,7 +125,7 @@ func Loop(ev *event.State) { if len(ev.Controllers) > 0 { for idx, ctrl := range ev.Controllers { SetControllerIndex(idx) - log.Info("gamepad: using controller #%d (%s) as Player 1", idx, ctrl) + log.Info("Gamepad: using controller #%d (%d) as Player 1", idx, ctrl.Name()) break } } else { diff --git a/pkg/keybind/keybind.go b/pkg/keybind/keybind.go index 771484b..aa4456f 100644 --- a/pkg/keybind/keybind.go +++ b/pkg/keybind/keybind.go @@ -276,7 +276,17 @@ func Use(ev *event.State) bool { return ev.Space || ev.KeyDown("q") } +// LeftClick of the primary mouse button. +func LeftClick(ev *event.State) bool { + return ev.Button1 +} + // MiddleClick of the mouse for panning the level. func MiddleClick(ev *event.State) bool { return ev.Button2 } + +// ClearLeftClick sets the primary mouse button state to false. +func ClearLeftClick(ev *event.State) { + ev.Button1 = false +} diff --git a/pkg/level/palette.go b/pkg/level/palette.go index 6dc8b9a..5264135 100644 --- a/pkg/level/palette.go +++ b/pkg/level/palette.go @@ -90,6 +90,14 @@ func (p *Palette) Inflate() { p.update() } +// FlushCaches if you have modified the swatches, especially if you have +// changed the name of an existing color. This invalidates the "by name" +// cache and rebuilds it from scratch. +func (p *Palette) FlushCaches() { + p.byName = nil + p.update() +} + // AddSwatch adds a new swatch to the palette. func (p *Palette) AddSwatch() *Swatch { p.update() diff --git a/pkg/level/types.go b/pkg/level/types.go index ac95324..7626d1e 100644 --- a/pkg/level/types.go +++ b/pkg/level/types.go @@ -3,6 +3,7 @@ package level import ( "encoding/json" "fmt" + "os" "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/drawtool" @@ -31,8 +32,8 @@ type Base struct { // Level is the container format for Doodle map drawings. type Level struct { Base - Password string `json:"passwd"` - Difficulty enum.Difficulty `json:"difficulty"` + Password string `json:"passwd"` + GameRule GameRule `json:"rules"` // Chunked pixel data. Chunker *Chunker `json:"chunks"` @@ -58,11 +59,19 @@ type Level struct { UndoHistory *drawtool.History `json:"-"` } +// GameRule +type GameRule struct { + Difficulty enum.Difficulty `json:"difficulty"` + Survival bool `json:"survival"` +} + // New creates a blank level object with all its members initialized. func New() *Level { return &Level{ Base: Base{ Version: 1, + Title: "Untitled", + Author: os.Getenv("USER"), }, Chunker: NewChunker(balance.ChunkSize), Palette: &Palette{}, diff --git a/pkg/play_scene.go b/pkg/play_scene.go index d4b07ec..818b1b0 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -576,7 +576,7 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { log.Info("Mark level '%s' from pack '%s' as completed", s.Filename, s.LevelPack.Filename) if !s.cheated { elapsed := time.Since(s.startTime) - highscore := save.NewHighScore(s.LevelPack.Filename, s.Filename, s.perfectRun, elapsed) + highscore := save.NewHighScore(s.LevelPack.Filename, s.Filename, s.perfectRun, elapsed, s.Level.GameRule) if highscore { s.d.Flash("New record!") config.NewRecord = true diff --git a/pkg/savegame/savegame.go b/pkg/savegame/savegame.go index e46903d..b0f6499 100644 --- a/pkg/savegame/savegame.go +++ b/pkg/savegame/savegame.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/usercfg" "git.kirsle.net/apps/doodle/pkg/userdir" ) @@ -130,7 +131,7 @@ func (sg *SaveGame) MarkCompleted(levelpack, filename string) { // than the stored one it will update. // // Returns true if a new high score was logged. -func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, elapsed time.Duration) bool { +func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, elapsed time.Duration, rules level.GameRule) bool { levelpack = filepath.Base(levelpack) filename = filepath.Base(filename) @@ -144,9 +145,19 @@ func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, ela newHigh = true } } else { - if score.BestTime == nil || *score.BestTime > elapsed { - score.BestTime = &elapsed - newHigh = true + // GameRule: Survival (silver) - high score is based on longest time left alive rather + // than fastest time completed. + if rules.Survival { + if score.BestTime == nil || *score.BestTime < elapsed { + score.BestTime = &elapsed + newHigh = true + } + } else { + // Normally: fastest time is best time. + if score.BestTime == nil || *score.BestTime > elapsed { + score.BestTime = &elapsed + newHigh = true + } } } diff --git a/pkg/uix/canvas_editable.go b/pkg/uix/canvas_editable.go index 9215894..cb7d006 100644 --- a/pkg/uix/canvas_editable.go +++ b/pkg/uix/canvas_editable.go @@ -5,6 +5,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/keybind" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/go/render" "git.kirsle.net/go/render/event" @@ -107,14 +108,8 @@ func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) { } // Add the stroke to level history. - if w.level != nil && addHistory { - w.level.UndoHistory.AddStroke(w.currentStroke) - } else if w.doodad != nil && addHistory { - if w.doodad.UndoHistory == nil { - // HACK: if UndoHistory was not initialized properly. - w.doodad.UndoHistory = drawtool.NewHistory(balance.UndoHistory) - } - w.doodad.UndoHistory.AddStroke(w.currentStroke) + if addHistory { + w.strokeToHistory(w.currentStroke) } w.RemoveStroke(w.currentStroke) @@ -123,6 +118,19 @@ func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) { w.lastPixel = nil } +// Add a recently drawn stroke to the UndoHistory. +func (w *Canvas) strokeToHistory(stroke *drawtool.Stroke) { + if w.level != nil { + w.level.UndoHistory.AddStroke(stroke) + } else if w.doodad != nil { + if w.doodad.UndoHistory == nil { + // HACK: if UndoHistory was not initialized properly. + w.doodad.UndoHistory = drawtool.NewHistory(balance.UndoHistory) + } + w.doodad.UndoHistory.AddStroke(stroke) + } +} + // loopEditable handles the Loop() part for editable canvases. func (w *Canvas) loopEditable(ev *event.State) error { // Get the absolute position of the canvas on screen to accurately match @@ -146,7 +154,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { switch w.Tool { case drawtool.PanTool: // Pan tool = click to pan the level. - if ev.Button1 || keybind.MiddleClick(ev) { + if keybind.LeftClick(ev) || keybind.MiddleClick(ev) { if !w.scrollDragging { w.scrollDragging = true w.scrollStartAt = shmem.Cursor @@ -175,7 +183,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } // Clicking? Log all the pixels while doing so. - if ev.Button1 { + if keybind.LeftClick(ev) { // Initialize a new Stroke for this atomic drawing operation? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) @@ -221,6 +229,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } else { w.commitStroke(w.Tool, true) } + case drawtool.LineTool: // If no swatch is active, do nothing with mouse clicks. if w.Palette.ActiveSwatch == nil { @@ -228,7 +237,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } // Clicking? Log all the pixels while doing so. - if ev.Button1 { + if keybind.LeftClick(ev) { // Initialize a new Stroke for this atomic drawing operation? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color) @@ -243,6 +252,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } else { w.commitStroke(w.Tool, true) } + case drawtool.RectTool: // If no swatch is active, do nothing with mouse clicks. if w.Palette.ActiveSwatch == nil { @@ -250,7 +260,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } // Clicking? Log all the pixels while doing so. - if ev.Button1 { + if keybind.LeftClick(ev) { // Initialize a new Stroke for this atomic drawing operation? if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color) @@ -265,12 +275,13 @@ func (w *Canvas) loopEditable(ev *event.State) error { } else { w.commitStroke(w.Tool, true) } + case drawtool.EllipseTool: if w.Palette.ActiveSwatch == nil { return nil } - if ev.Button1 { + if keybind.LeftClick(ev) { if w.currentStroke == nil { w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color) w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern @@ -284,6 +295,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } else { w.commitStroke(w.Tool, true) } + case drawtool.TextTool: // The Text Tool popup should initialize this for us, if somehow not // initialized skip this tool processing. @@ -312,7 +324,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { // at the cursor location while the TextTool is active. // On mouse click, commit the text to the drawing. - if ev.Button1 { + if keybind.LeftClick(ev) { if stroke, err := drawtool.TT.ToStroke(shmem.CurrentRenderEngine, w.Palette.ActiveSwatch.Color, cursor); err != nil { shmem.FlashError("Text Tool error: %s", err) return nil @@ -322,12 +334,83 @@ func (w *Canvas) loopEditable(ev *event.State) error { w.commitStroke(drawtool.PencilTool, true) } - ev.Button1 = false + keybind.ClearLeftClick(ev) + } + + case drawtool.FloodTool: + if w.Palette.ActiveSwatch == nil { + return nil + } + + // Click to activate. + if keybind.LeftClick(ev) { + var ( + chunker = w.Chunker() + stroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) + ) + + // Set some max boundaries to prevent runaway infinite loops, e.g. if user + // clicked the wide open void the flood fill would never finish! + limit := balance.FloodToolLimit + + // Get the original color at this location. + // Error cases can include: no chunk at this spot, or no pixel at this spot. + // Treat these as just a null color and proceed anyway, user should be able + // to flood fill blank areas of their level. + baseColor, err := chunker.Get(cursor) + if err != nil { + limit = balance.FloodToolVoidLimit + log.Warn("FloodTool: couldn't get base color at %s: %s (got %s)", cursor, err) + } + + // If no change, do nothing. + if baseColor == w.Palette.ActiveSwatch { + break + } + + // The flood fill algorithm. + queue := []render.Point{cursor} + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + + colorAt, _ := chunker.Get(node) + if colorAt != baseColor { + continue + } + + // For Undo history, store the original color at this point. + if colorAt != nil { + stroke.OriginalPoints[node] = colorAt + } + + // Add the neighboring pixels. + for _, neighbor := range []render.Point{ + {X: node.X - 1, Y: node.Y}, + {X: node.X + 1, Y: node.Y}, + {X: node.X, Y: node.Y - 1}, + {X: node.X, Y: node.Y + 1}, + } { + // Only if not too far from the origin! + if render.AbsInt(neighbor.X-cursor.X) <= limit && render.AbsInt(neighbor.Y-cursor.Y) <= limit { + queue = append(queue, neighbor) + } + } + + stroke.AddPoint(node) + err = chunker.Set(node, w.Palette.ActiveSwatch) + if err != nil { + log.Error("FloodTool: error setting %s to %s: %s", node, w.Palette.ActiveSwatch, err) + } + } + + w.strokeToHistory(stroke) + keybind.ClearLeftClick(ev) } case drawtool.EraserTool: // Clicking? Log all the pixels while doing so. - if ev.Button1 { + if keybind.LeftClick(ev) { // Initialize a new Stroke for this atomic drawing operation? if w.currentStroke == nil { // The color is white, will look like white-out that covers the @@ -370,6 +453,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { } else { w.commitStroke(w.Tool, true) } + case drawtool.ActorTool: // See if any of the actors are below the mouse cursor. var WP = w.WorldIndexAt(cursor) @@ -406,7 +490,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { // Check for a mouse down event to begin dragging this // canvas around. - if ev.Button1 { + if keybind.LeftClick(ev) { // Pop this canvas out for the drag/drop. if w.OnDragStart != nil { deleteActors = append(deleteActors, actor.Actor) @@ -427,6 +511,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { if len(deleteActors) > 0 && w.OnDeleteActors != nil { w.OnDeleteActors(deleteActors) } + case drawtool.LinkTool: // See if any of the actors are below the mouse cursor. var WP = w.WorldIndexAt(cursor) @@ -467,7 +552,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { }) // Click handler to start linking this actor. - if ev.Button1 { + if keybind.LeftClick(ev) { if err := w.LinkAdd(actor); err != nil { return err } @@ -475,7 +560,7 @@ func (w *Canvas) loopEditable(ev *event.State) error { // TODO: reset the Button1 state so we don't finish a // link and then LinkAdd the clicked doodad immediately // (causing link chaining) - ev.Button1 = false + keybind.ClearLeftClick(ev) break } } else { diff --git a/pkg/uix/scripting.go b/pkg/uix/scripting.go index 3223e43..911faab 100644 --- a/pkg/uix/scripting.go +++ b/pkg/uix/scripting.go @@ -66,7 +66,7 @@ func (w *Canvas) MakeScriptAPI(vm *scripting.VM) { }) vm.Set("Level", map[string]interface{}{ - "Difficulty": w.level.Difficulty, + "Difficulty": w.level.GameRule.Difficulty, }) } diff --git a/pkg/windows/add_edit_level.go b/pkg/windows/add_edit_level.go index 9317f78..2f34954 100644 --- a/pkg/windows/add_edit_level.go +++ b/pkg/windows/add_edit_level.go @@ -1,6 +1,8 @@ package windows import ( + "fmt" + "regexp" "strconv" "git.kirsle.net/apps/doodle/pkg/balance" @@ -48,15 +50,12 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window { window.SetButtons(ui.CloseButton) window.Configure(ui.Config{ Width: 400, - Height: 280, + Height: 290, Background: render.Grey, }) // Tabbed UI for New Level or New Doodad. tabframe := ui.NewTabFrame("Level Tabs") - if config.EditLevel != nil { - tabframe.SetTabsHidden(true) - } window.Pack(tabframe, ui.Pack{ Side: ui.N, Fill: true, @@ -64,8 +63,14 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window { }) // Add the tabs. - config.setupLevelFrame(tabframe) - config.setupDoodadFrame(tabframe) + config.setupLevelFrame(tabframe) // Level Properties (always) + if config.EditLevel == nil { + // New Doodad properties (New window only) + config.setupDoodadFrame(tabframe) + } else { + // Additional Level tabs (existing level only) + config.setupGameRuleFrame(tabframe) + } tabframe.Supervise(config.Supervisor) @@ -77,6 +82,7 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window { func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { // Default options. var ( + tabLabel = "New Level" newPageType = level.Bounded.String() newWallpaper = "notebook.png" paletteName = level.DefaultPaletteNames[0] @@ -94,13 +100,14 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { // Given a level to edit? if !isNewLevel { + tabLabel = "Properties" newPageType = config.EditLevel.PageType.String() newWallpaper = config.EditLevel.Wallpaper paletteName = textCurrentPalette } frame := tf.AddTab("index", ui.NewLabel(ui.Label{ - Text: "New Level", + Text: tabLabel, Font: balance.TabFont, })) @@ -126,10 +133,6 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { Label: "Page type:", Font: balance.UIFont, Options: []magicform.Option{ - { - Label: "Bounded", - Value: level.Bounded, - }, { Label: "Bounded", Value: level.Bounded, @@ -286,35 +289,33 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { ******************/ if config.EditLevel != nil { + var ( + levelSizeStr = fmt.Sprintf("%dx%d", config.EditLevel.MaxWidth, config.EditLevel.MaxHeight) + levelSizeRegexp = regexp.MustCompile(`^(\d+)x(\d+)$`) + ) fields = append(fields, []magicform.Field{ { - Label: "Difficulty:", - Font: balance.UIFont, - SelectValue: config.EditLevel.Difficulty, - Tooltip: ui.Tooltip{ - Text: "Peaceful: enemies may not attack\n" + - "Normal: default difficulty\n" + - "Hard: enemies may be more aggressive", - Edge: ui.Top, - }, - Options: []magicform.Option{ - { - Label: "Peaceful", - Value: enum.Peaceful, - }, - { - Label: "Normal (recommended)", - Value: enum.Normal, - }, - { - Label: "Hard", - Value: enum.Hard, - }, - }, - OnSelect: func(v interface{}) { - value, _ := v.(enum.Difficulty) - config.EditLevel.Difficulty = value - log.Info("Set level difficulty to: %d (%s)", value, value) + Label: "Limits (bounded):", + Font: balance.UIFont, + TextVariable: &levelSizeStr, + OnClick: func() { + shmem.Prompt(fmt.Sprintf("Enter new limits in WxH format or [%s]: ", levelSizeStr), func(answer string) { + if answer == "" { + return + } + + match := levelSizeRegexp.FindStringSubmatch(answer) + if match == nil { + return + } + + levelSizeStr = match[0] + width, _ := strconv.Atoi(match[1]) + height, _ := strconv.Atoi(match[2]) + + config.EditLevel.MaxWidth = int64(width) + config.EditLevel.MaxHeight = int64(height) + }) }, }, { @@ -341,7 +342,7 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { } // The confirm/cancel buttons. - var okLabel = "Ok" + var okLabel = "Apply" if config.EditLevel == nil { okLabel = "Continue" } @@ -537,3 +538,70 @@ func (config AddEditLevel) setupDoodadFrame(tf *ui.TabFrame) { }) } } + +// Creates the Game Rules frame for existing level (set difficulty, etc.) +func (config AddEditLevel) setupGameRuleFrame(tf *ui.TabFrame) { + frame := tf.AddTab("GameRules", ui.NewLabel(ui.Label{ + Text: "Game Rules", + Font: balance.TabFont, + })) + + form := magicform.Form{ + Supervisor: config.Supervisor, + Engine: config.Engine, + Vertical: true, + LabelWidth: 120, + PadY: 2, + } + fields := []magicform.Field{ + { + Label: "Game Rules are specific to this level and can change some of\n" + + "the game's default behaviors.", + Font: balance.UIFont, + }, + { + Label: "Difficulty:", + Font: balance.UIFont, + SelectValue: config.EditLevel.GameRule.Difficulty, + Tooltip: ui.Tooltip{ + Text: "Peaceful: enemies may not attack\n" + + "Normal: default difficulty\n" + + "Hard: enemies may be more aggressive", + Edge: ui.Top, + }, + Options: []magicform.Option{ + { + Label: "Peaceful", + Value: enum.Peaceful, + }, + { + Label: "Normal (recommended)", + Value: enum.Normal, + }, + { + Label: "Hard", + Value: enum.Hard, + }, + }, + OnSelect: func(v interface{}) { + value, _ := v.(enum.Difficulty) + config.EditLevel.GameRule.Difficulty = value + log.Info("Set level difficulty to: %d (%s)", value, value) + }, + }, + { + Label: "Survival Mode (silver high score)", + Font: balance.UIFont, + BoolVariable: &config.EditLevel.GameRule.Survival, + Tooltip: ui.Tooltip{ + Text: "Use for levels where dying at least once is very likely\n" + + "(e.g. Azulian Tag). The silver high score will be for\n" + + "longest time rather than fastest time. The gold high\n" + + "score will still be for fastest time.", + Edge: ui.Top, + }, + }, + } + + form.Create(frame, fields) +} diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go index 9f82a52..2fcd5a4 100644 --- a/pkg/windows/levelpack_open.go +++ b/pkg/windows/levelpack_open.go @@ -466,7 +466,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp btn := ui.NewButton(level.Filename, btnFrame) btn.Handle(ui.Click, func(ed ui.EventData) error { // Is this level locked? - if locked { + if locked && !balance.CheatEnabledUnlockLevels { modal.Alert( "This level hasn't been unlocked! Complete the earlier\n" + "levels in this pack to unlock later levels.", diff --git a/pkg/windows/palette_editor.go b/pkg/windows/palette_editor.go index de64137..57fb1b5 100644 --- a/pkg/windows/palette_editor.go +++ b/pkg/windows/palette_editor.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/pattern" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/usercfg" @@ -118,6 +119,8 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { // Draw the main table of Palette rows. if pal := config.EditPalette; pal != nil { for i, swatch := range pal.Swatches { + i := i + var idStr = fmt.Sprintf("%d", i) swatch := swatch @@ -153,6 +156,14 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { shmem.Prompt("New swatch name ["+swatch.Name+"]: ", func(answer string) { log.Warn("Answer: %s", answer) if answer != "" { + // Confirm it is unique. + for j, exist := range pal.Swatches { + if exist.Name == answer && i != j { + modal.Alert("That name is already used by another color.") + return + } + } + swatch.Name = answer if config.OnChange != nil { config.OnChange() @@ -166,19 +177,8 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { ////////////// // Color Choice button. btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame")) - btnColor.SetStyle(&style.Button{ - Background: swatch.Color, - HoverBackground: swatch.Color.Lighten(40), - OutlineColor: render.Black, - OutlineSize: 1, - BorderStyle: style.BorderRaised, - BorderSize: 2, - }) - btnColor.Configure(ui.Config{ - Background: swatch.Color, - Width: col2, - Height: 24, - }) + setPaletteButtonColor(btnColor, swatch.Color) + btnColor.Resize(render.NewRect(col2, 24)) btnColor.Handle(ui.Click, func(ed ui.EventData) error { // Open a ColorPicker widget. picker, err := ui.NewColorPicker(ui.ColorPicker{ @@ -214,17 +214,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { picker.Then(func(color render.Color) { swatch.Color = color - - // TODO: redundant from above, consolidate these - fmt.Printf("Set button style to: %s\n", swatch.Color) - btnColor.SetStyle(&style.Button{ - Background: swatch.Color, - HoverBackground: swatch.Color.Lighten(40), - OutlineColor: render.Black, - OutlineSize: 1, - BorderStyle: style.BorderRaised, - BorderSize: 2, - }) + setPaletteButtonColor(btnColor, color) if config.OnChange != nil { config.OnChange() @@ -243,10 +233,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window { selTexture := ui.NewSelectBox("Texture", ui.Label{ Font: balance.MenuFont, }) - selTexture.Configure(ui.Config{ - Width: col5, - Height: 24, - }) + selTexture.Resize(render.NewRect(col5, 24)) for _, t := range pattern.Builtins { if t.Hidden && !usercfg.Current.ShowHiddenDoodads { @@ -459,3 +446,15 @@ func setImageOnSelect(sel *ui.SelectBox, filename string) { sel.SetImage(nil) } } + +// Helper function to assign a palette "color button" color. +func setPaletteButtonColor(btn *ui.Button, color render.Color) { + btn.SetStyle(&style.Button{ + Background: color, + HoverBackground: color.Lighten(40), + OutlineColor: render.Black, + OutlineSize: 1, + BorderStyle: style.BorderRaised, + BorderSize: 2, + }) +}