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.
pull/84/head
Noah 2022-03-26 13:55:06 -07:00
parent bf706efdc6
commit af6b8625d6
22 changed files with 354 additions and 125 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -4,7 +4,7 @@ const color = Self.GetTag("color");
var playerSpeed = color === 'blue' ? 2 : 4, var playerSpeed = color === 'blue' ? 2 : 4,
aggroX = 250, // X/Y distance sensitivity from player aggroX = 250, // X/Y distance sensitivity from player
aggroY = color === 'blue' ? 100 : 200, aggroY = color === 'blue' ? 100 : 200,
jumpSpeed = color === 'blue' ? 12 : 14, jumpSpeed = color === 'blue' ? 14 : 18,
animating = false, animating = false,
direction = "right", direction = "right",
lastDirection = "right"; lastDirection = "right";
@ -14,7 +14,7 @@ if (color === 'white') {
aggroX = 1000; aggroX = 1000;
aggroY = 400; aggroY = 400;
playerSpeed = 8; playerSpeed = 8;
jumpSpeed = 16; jumpSpeed = 20;
} }
function setupAnimations(color) { function setupAnimations(color) {

View File

@ -32,4 +32,10 @@ var (
CheatPlayAsAnvil = "megaton weight" CheatPlayAsAnvil = "megaton weight"
CheatGodMode = "god mode" CheatGodMode = "god mode"
CheatDebugLoadScreen = "test load screen" CheatDebugLoadScreen = "test load screen"
CheatUnlockLevels = "master key"
)
// Global cheat boolean states.
var (
CheatEnabledUnlockLevels bool
) )

View File

@ -4,8 +4,7 @@ package balance
var Feature = feature{ var Feature = feature{
///////// /////////
// Experimental features that are off by default // Experimental features that are off by default
Zoom: false, // enable the zoom in/out feature (very buggy rn) ViewportWindow: false, // Open new viewport into your level
ChangePalette: false, // reset your palette after level creation to a diff preset
///////// /////////
// Fully activated features // Fully activated features
@ -15,12 +14,17 @@ var Feature = feature{
// Allow embedded doodads in levels. // Allow embedded doodads in levels.
EmbeddableDoodads: true, 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. // FeaturesOn turns on all feature flags, from CLI --experimental option.
func FeaturesOn() { func FeaturesOn() {
Feature.Zoom = true Feature.ViewportWindow = true
Feature.ChangePalette = true
} }
type feature struct { type feature struct {
@ -28,4 +32,5 @@ type feature struct {
CustomWallpaper bool CustomWallpaper bool
ChangePalette bool ChangePalette bool
EmbeddableDoodads bool EmbeddableDoodads bool
ViewportWindow bool
} }

View File

@ -101,6 +101,10 @@ var (
// GameController thresholds. // GameController thresholds.
GameControllerMouseMoveMax float64 = 20 // Max pixels per tick to simulate mouse movement. GameControllerMouseMoveMax float64 = 20 // Max pixels per tick to simulate mouse movement.
GameControllerScrollMin float64 = 0.3 // Minimum threshold for a right-stick scroll event. 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 // Edit Mode Values

View File

@ -161,6 +161,14 @@ func (c Command) cheatCommand(d *Doodle) bool {
loadscreen.Hide() 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: default:
return false return false
} }

View File

@ -14,6 +14,7 @@ const (
EraserTool EraserTool
PanTool PanTool
TextTool TextTool
FloodTool
) )
var toolNames = []string{ var toolNames = []string{
@ -26,6 +27,7 @@ var toolNames = []string{
"Eraser", "Eraser",
"PanTool", "PanTool",
"TextTool", "TextTool",
"FloodTool",
} }
func (t Tool) String() string { func (t Tool) String() string {

View File

@ -5,13 +5,13 @@ package doodle
// The rest of it is controlled in editor_ui.go // The rest of it is controlled in editor_ui.go
import ( import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/level/giant_screenshot" "git.kirsle.net/apps/doodle/pkg/level/giant_screenshot"
"git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/license"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/native" "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/userdir"
"git.kirsle.net/apps/doodle/pkg/windows" "git.kirsle.net/apps/doodle/pkg/windows"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
@ -137,7 +137,7 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar {
native.OpenLocalURL(userdir.ScreenshotDirectory) native.OpenLocalURL(userdir.ScreenshotDirectory)
}) })
if usercfg.Current.EnableFeatures { if balance.Feature.ViewportWindow {
levelMenu.AddSeparator() levelMenu.AddSeparator()
levelMenu.AddItemAccel("New viewport", "v", func() { levelMenu.AddItemAccel("New viewport", "v", func() {
pip := windows.MakePiPWindow(d.width, d.height, windows.PiP{ pip := windows.MakePiPWindow(d.width, d.height, windows.PiP{

View File

@ -298,6 +298,10 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
u.Scene.Doodad.Layers[u.Scene.ActiveLayer].Chunker.Redraw() 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. // Reload the palette frame to reflect the changed data.
u.Palette.Hide() u.Palette.Hide()
u.Palette = u.SetupPalette(d) u.Palette = u.SetupPalette(d)

View File

@ -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(), Value: drawtool.ActorTool.String(),
Icon: "assets/sprites/actor-tool.png", 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.") 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. // Arrange the buttons 2x2.

View File

@ -38,8 +38,8 @@ const (
func (d Difficulty) String() string { func (d Difficulty) String() string {
return []string{ return []string{
"Peaceful",
"Normal", "Normal",
"Hard", "Hard",
"Peaceful", }[d+1]
}[d]
} }

View File

@ -125,7 +125,7 @@ func Loop(ev *event.State) {
if len(ev.Controllers) > 0 { if len(ev.Controllers) > 0 {
for idx, ctrl := range ev.Controllers { for idx, ctrl := range ev.Controllers {
SetControllerIndex(idx) 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 break
} }
} else { } else {

View File

@ -276,7 +276,17 @@ func Use(ev *event.State) bool {
return ev.Space || ev.KeyDown("q") 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. // MiddleClick of the mouse for panning the level.
func MiddleClick(ev *event.State) bool { func MiddleClick(ev *event.State) bool {
return ev.Button2 return ev.Button2
} }
// ClearLeftClick sets the primary mouse button state to false.
func ClearLeftClick(ev *event.State) {
ev.Button1 = false
}

View File

@ -90,6 +90,14 @@ func (p *Palette) Inflate() {
p.update() 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. // AddSwatch adds a new swatch to the palette.
func (p *Palette) AddSwatch() *Swatch { func (p *Palette) AddSwatch() *Swatch {
p.update() p.update()

View File

@ -3,6 +3,7 @@ package level
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/drawtool"
@ -31,8 +32,8 @@ type Base struct {
// Level is the container format for Doodle map drawings. // Level is the container format for Doodle map drawings.
type Level struct { type Level struct {
Base Base
Password string `json:"passwd"` Password string `json:"passwd"`
Difficulty enum.Difficulty `json:"difficulty"` GameRule GameRule `json:"rules"`
// Chunked pixel data. // Chunked pixel data.
Chunker *Chunker `json:"chunks"` Chunker *Chunker `json:"chunks"`
@ -58,11 +59,19 @@ type Level struct {
UndoHistory *drawtool.History `json:"-"` 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. // New creates a blank level object with all its members initialized.
func New() *Level { func New() *Level {
return &Level{ return &Level{
Base: Base{ Base: Base{
Version: 1, Version: 1,
Title: "Untitled",
Author: os.Getenv("USER"),
}, },
Chunker: NewChunker(balance.ChunkSize), Chunker: NewChunker(balance.ChunkSize),
Palette: &Palette{}, Palette: &Palette{},

View File

@ -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) log.Info("Mark level '%s' from pack '%s' as completed", s.Filename, s.LevelPack.Filename)
if !s.cheated { if !s.cheated {
elapsed := time.Since(s.startTime) 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 { if highscore {
s.d.Flash("New record!") s.d.Flash("New record!")
config.NewRecord = true config.NewRecord = true

View File

@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/usercfg" "git.kirsle.net/apps/doodle/pkg/usercfg"
"git.kirsle.net/apps/doodle/pkg/userdir" "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. // than the stored one it will update.
// //
// Returns true if a new high score was logged. // 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) levelpack = filepath.Base(levelpack)
filename = filepath.Base(filename) filename = filepath.Base(filename)
@ -144,9 +145,19 @@ func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, ela
newHigh = true newHigh = true
} }
} else { } else {
if score.BestTime == nil || *score.BestTime > elapsed { // GameRule: Survival (silver) - high score is based on longest time left alive rather
score.BestTime = &elapsed // than fastest time completed.
newHigh = true 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
}
} }
} }

View File

@ -5,6 +5,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/keybind" "git.kirsle.net/apps/doodle/pkg/keybind"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/render/event" "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. // Add the stroke to level history.
if w.level != nil && addHistory { if addHistory {
w.level.UndoHistory.AddStroke(w.currentStroke) w.strokeToHistory(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)
} }
w.RemoveStroke(w.currentStroke) w.RemoveStroke(w.currentStroke)
@ -123,6 +118,19 @@ func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) {
w.lastPixel = nil 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. // loopEditable handles the Loop() part for editable canvases.
func (w *Canvas) loopEditable(ev *event.State) error { func (w *Canvas) loopEditable(ev *event.State) error {
// Get the absolute position of the canvas on screen to accurately match // 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 { switch w.Tool {
case drawtool.PanTool: case drawtool.PanTool:
// Pan tool = click to pan the level. // Pan tool = click to pan the level.
if ev.Button1 || keybind.MiddleClick(ev) { if keybind.LeftClick(ev) || keybind.MiddleClick(ev) {
if !w.scrollDragging { if !w.scrollDragging {
w.scrollDragging = true w.scrollDragging = true
w.scrollStartAt = shmem.Cursor w.scrollStartAt = shmem.Cursor
@ -175,7 +183,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} }
// Clicking? Log all the pixels while doing so. // Clicking? Log all the pixels while doing so.
if ev.Button1 { if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Freehand, w.Palette.ActiveSwatch.Color)
@ -221,6 +229,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} else { } else {
w.commitStroke(w.Tool, true) w.commitStroke(w.Tool, true)
} }
case drawtool.LineTool: case drawtool.LineTool:
// If no swatch is active, do nothing with mouse clicks. // If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil { 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. // Clicking? Log all the pixels while doing so.
if ev.Button1 { if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Line, w.Palette.ActiveSwatch.Color)
@ -243,6 +252,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} else { } else {
w.commitStroke(w.Tool, true) w.commitStroke(w.Tool, true)
} }
case drawtool.RectTool: case drawtool.RectTool:
// If no swatch is active, do nothing with mouse clicks. // If no swatch is active, do nothing with mouse clicks.
if w.Palette.ActiveSwatch == nil { 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. // Clicking? Log all the pixels while doing so.
if ev.Button1 { if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Rectangle, w.Palette.ActiveSwatch.Color)
@ -265,12 +275,13 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} else { } else {
w.commitStroke(w.Tool, true) w.commitStroke(w.Tool, true)
} }
case drawtool.EllipseTool: case drawtool.EllipseTool:
if w.Palette.ActiveSwatch == nil { if w.Palette.ActiveSwatch == nil {
return nil return nil
} }
if ev.Button1 { if keybind.LeftClick(ev) {
if w.currentStroke == nil { if w.currentStroke == nil {
w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color) w.currentStroke = drawtool.NewStroke(drawtool.Ellipse, w.Palette.ActiveSwatch.Color)
w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern w.currentStroke.Pattern = w.Palette.ActiveSwatch.Pattern
@ -284,6 +295,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
} else { } else {
w.commitStroke(w.Tool, true) w.commitStroke(w.Tool, true)
} }
case drawtool.TextTool: case drawtool.TextTool:
// The Text Tool popup should initialize this for us, if somehow not // The Text Tool popup should initialize this for us, if somehow not
// initialized skip this tool processing. // 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. // at the cursor location while the TextTool is active.
// On mouse click, commit the text to the drawing. // 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 { if stroke, err := drawtool.TT.ToStroke(shmem.CurrentRenderEngine, w.Palette.ActiveSwatch.Color, cursor); err != nil {
shmem.FlashError("Text Tool error: %s", err) shmem.FlashError("Text Tool error: %s", err)
return nil return nil
@ -322,12 +334,83 @@ func (w *Canvas) loopEditable(ev *event.State) error {
w.commitStroke(drawtool.PencilTool, true) 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: case drawtool.EraserTool:
// Clicking? Log all the pixels while doing so. // Clicking? Log all the pixels while doing so.
if ev.Button1 { if keybind.LeftClick(ev) {
// Initialize a new Stroke for this atomic drawing operation? // Initialize a new Stroke for this atomic drawing operation?
if w.currentStroke == nil { if w.currentStroke == nil {
// The color is white, will look like white-out that covers the // 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 { } else {
w.commitStroke(w.Tool, true) w.commitStroke(w.Tool, true)
} }
case drawtool.ActorTool: case drawtool.ActorTool:
// See if any of the actors are below the mouse cursor. // See if any of the actors are below the mouse cursor.
var WP = w.WorldIndexAt(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 // Check for a mouse down event to begin dragging this
// canvas around. // canvas around.
if ev.Button1 { if keybind.LeftClick(ev) {
// Pop this canvas out for the drag/drop. // Pop this canvas out for the drag/drop.
if w.OnDragStart != nil { if w.OnDragStart != nil {
deleteActors = append(deleteActors, actor.Actor) deleteActors = append(deleteActors, actor.Actor)
@ -427,6 +511,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
if len(deleteActors) > 0 && w.OnDeleteActors != nil { if len(deleteActors) > 0 && w.OnDeleteActors != nil {
w.OnDeleteActors(deleteActors) w.OnDeleteActors(deleteActors)
} }
case drawtool.LinkTool: case drawtool.LinkTool:
// See if any of the actors are below the mouse cursor. // See if any of the actors are below the mouse cursor.
var WP = w.WorldIndexAt(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. // Click handler to start linking this actor.
if ev.Button1 { if keybind.LeftClick(ev) {
if err := w.LinkAdd(actor); err != nil { if err := w.LinkAdd(actor); err != nil {
return err 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 // TODO: reset the Button1 state so we don't finish a
// link and then LinkAdd the clicked doodad immediately // link and then LinkAdd the clicked doodad immediately
// (causing link chaining) // (causing link chaining)
ev.Button1 = false keybind.ClearLeftClick(ev)
break break
} }
} else { } else {

View File

@ -66,7 +66,7 @@ func (w *Canvas) MakeScriptAPI(vm *scripting.VM) {
}) })
vm.Set("Level", map[string]interface{}{ vm.Set("Level", map[string]interface{}{
"Difficulty": w.level.Difficulty, "Difficulty": w.level.GameRule.Difficulty,
}) })
} }

View File

@ -1,6 +1,8 @@
package windows package windows
import ( import (
"fmt"
"regexp"
"strconv" "strconv"
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
@ -48,15 +50,12 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window {
window.SetButtons(ui.CloseButton) window.SetButtons(ui.CloseButton)
window.Configure(ui.Config{ window.Configure(ui.Config{
Width: 400, Width: 400,
Height: 280, Height: 290,
Background: render.Grey, Background: render.Grey,
}) })
// Tabbed UI for New Level or New Doodad. // Tabbed UI for New Level or New Doodad.
tabframe := ui.NewTabFrame("Level Tabs") tabframe := ui.NewTabFrame("Level Tabs")
if config.EditLevel != nil {
tabframe.SetTabsHidden(true)
}
window.Pack(tabframe, ui.Pack{ window.Pack(tabframe, ui.Pack{
Side: ui.N, Side: ui.N,
Fill: true, Fill: true,
@ -64,8 +63,14 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window {
}) })
// Add the tabs. // Add the tabs.
config.setupLevelFrame(tabframe) config.setupLevelFrame(tabframe) // Level Properties (always)
config.setupDoodadFrame(tabframe) 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) tabframe.Supervise(config.Supervisor)
@ -77,6 +82,7 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window {
func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) { func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) {
// Default options. // Default options.
var ( var (
tabLabel = "New Level"
newPageType = level.Bounded.String() newPageType = level.Bounded.String()
newWallpaper = "notebook.png" newWallpaper = "notebook.png"
paletteName = level.DefaultPaletteNames[0] paletteName = level.DefaultPaletteNames[0]
@ -94,13 +100,14 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) {
// Given a level to edit? // Given a level to edit?
if !isNewLevel { if !isNewLevel {
tabLabel = "Properties"
newPageType = config.EditLevel.PageType.String() newPageType = config.EditLevel.PageType.String()
newWallpaper = config.EditLevel.Wallpaper newWallpaper = config.EditLevel.Wallpaper
paletteName = textCurrentPalette paletteName = textCurrentPalette
} }
frame := tf.AddTab("index", ui.NewLabel(ui.Label{ frame := tf.AddTab("index", ui.NewLabel(ui.Label{
Text: "New Level", Text: tabLabel,
Font: balance.TabFont, Font: balance.TabFont,
})) }))
@ -126,10 +133,6 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) {
Label: "Page type:", Label: "Page type:",
Font: balance.UIFont, Font: balance.UIFont,
Options: []magicform.Option{ Options: []magicform.Option{
{
Label: "Bounded",
Value: level.Bounded,
},
{ {
Label: "Bounded", Label: "Bounded",
Value: level.Bounded, Value: level.Bounded,
@ -286,35 +289,33 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) {
******************/ ******************/
if config.EditLevel != nil { 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{ fields = append(fields, []magicform.Field{
{ {
Label: "Difficulty:", Label: "Limits (bounded):",
Font: balance.UIFont, Font: balance.UIFont,
SelectValue: config.EditLevel.Difficulty, TextVariable: &levelSizeStr,
Tooltip: ui.Tooltip{ OnClick: func() {
Text: "Peaceful: enemies may not attack\n" + shmem.Prompt(fmt.Sprintf("Enter new limits in WxH format or [%s]: ", levelSizeStr), func(answer string) {
"Normal: default difficulty\n" + if answer == "" {
"Hard: enemies may be more aggressive", return
Edge: ui.Top, }
},
Options: []magicform.Option{ match := levelSizeRegexp.FindStringSubmatch(answer)
{ if match == nil {
Label: "Peaceful", return
Value: enum.Peaceful, }
},
{ levelSizeStr = match[0]
Label: "Normal (recommended)", width, _ := strconv.Atoi(match[1])
Value: enum.Normal, height, _ := strconv.Atoi(match[2])
},
{ config.EditLevel.MaxWidth = int64(width)
Label: "Hard", config.EditLevel.MaxHeight = int64(height)
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)
}, },
}, },
{ {
@ -341,7 +342,7 @@ func (config AddEditLevel) setupLevelFrame(tf *ui.TabFrame) {
} }
// The confirm/cancel buttons. // The confirm/cancel buttons.
var okLabel = "Ok" var okLabel = "Apply"
if config.EditLevel == nil { if config.EditLevel == nil {
okLabel = "Continue" 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)
}

View File

@ -466,7 +466,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
btn := ui.NewButton(level.Filename, btnFrame) btn := ui.NewButton(level.Filename, btnFrame)
btn.Handle(ui.Click, func(ed ui.EventData) error { btn.Handle(ui.Click, func(ed ui.EventData) error {
// Is this level locked? // Is this level locked?
if locked { if locked && !balance.CheatEnabledUnlockLevels {
modal.Alert( modal.Alert(
"This level hasn't been unlocked! Complete the earlier\n" + "This level hasn't been unlocked! Complete the earlier\n" +
"levels in this pack to unlock later levels.", "levels in this pack to unlock later levels.",

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log" "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/pattern"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/apps/doodle/pkg/usercfg" "git.kirsle.net/apps/doodle/pkg/usercfg"
@ -118,6 +119,8 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
// Draw the main table of Palette rows. // Draw the main table of Palette rows.
if pal := config.EditPalette; pal != nil { if pal := config.EditPalette; pal != nil {
for i, swatch := range pal.Swatches { for i, swatch := range pal.Swatches {
i := i
var idStr = fmt.Sprintf("%d", i) var idStr = fmt.Sprintf("%d", i)
swatch := swatch swatch := swatch
@ -153,6 +156,14 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
shmem.Prompt("New swatch name ["+swatch.Name+"]: ", func(answer string) { shmem.Prompt("New swatch name ["+swatch.Name+"]: ", func(answer string) {
log.Warn("Answer: %s", answer) log.Warn("Answer: %s", answer)
if 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 swatch.Name = answer
if config.OnChange != nil { if config.OnChange != nil {
config.OnChange() config.OnChange()
@ -166,19 +177,8 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
////////////// //////////////
// Color Choice button. // Color Choice button.
btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame")) btnColor := ui.NewButton("Color", ui.NewFrame("Color Frame"))
btnColor.SetStyle(&style.Button{ setPaletteButtonColor(btnColor, swatch.Color)
Background: swatch.Color, btnColor.Resize(render.NewRect(col2, 24))
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,
})
btnColor.Handle(ui.Click, func(ed ui.EventData) error { btnColor.Handle(ui.Click, func(ed ui.EventData) error {
// Open a ColorPicker widget. // Open a ColorPicker widget.
picker, err := ui.NewColorPicker(ui.ColorPicker{ picker, err := ui.NewColorPicker(ui.ColorPicker{
@ -214,17 +214,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
picker.Then(func(color render.Color) { picker.Then(func(color render.Color) {
swatch.Color = color swatch.Color = color
setPaletteButtonColor(btnColor, 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,
})
if config.OnChange != nil { if config.OnChange != nil {
config.OnChange() config.OnChange()
@ -243,10 +233,7 @@ func NewPaletteEditor(config PaletteEditor) *ui.Window {
selTexture := ui.NewSelectBox("Texture", ui.Label{ selTexture := ui.NewSelectBox("Texture", ui.Label{
Font: balance.MenuFont, Font: balance.MenuFont,
}) })
selTexture.Configure(ui.Config{ selTexture.Resize(render.NewRect(col5, 24))
Width: col5,
Height: 24,
})
for _, t := range pattern.Builtins { for _, t := range pattern.Builtins {
if t.Hidden && !usercfg.Current.ShowHiddenDoodads { if t.Hidden && !usercfg.Current.ShowHiddenDoodads {
@ -459,3 +446,15 @@ func setImageOnSelect(sel *ui.SelectBox, filename string) {
sel.SetImage(nil) 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,
})
}