Compare commits

..

13 Commits

17 changed files with 102 additions and 351 deletions

View File

@ -118,15 +118,6 @@ go generate opslag.de/schobers/tins2020/cmd/tins2020
go install opslag.de/schobers/tins2020/cmd/tins2020 -tags static -ldflags "-s -w" go install opslag.de/schobers/tins2020/cmd/tins2020 -tags static -ldflags "-s -w"
``` ```
If you want to use the Allegro backend you can add the build tag `allegro` to the `go install` command. E.g.:
```
go install opslag.de/schobers/tins2020/cmd/tins2020 -tags static,allegro -ldflags "-s -w"
```
In this case the SDL 2.0 prerequisite is replaced by the Allegro 5.2 (development libraries must be available for your C compiler).
## Command line interface
You can extract all resources embedded in the executable by running it from the command line with the `--extract-resources` flag. The resources will be extract in the current working directory. Note that the game will use the resources on disk first if they are available.
## Sources ## Sources
Can be found at https://opslag.de/schobers/tins2020 (Git repository). Can be found at https://opslag.de/schobers/tins2020 (Git repository).

View File

@ -96,7 +96,6 @@ func (b *BuyFlowerButton) updateTexts(ctx ui.Context) error {
if err := b.priceTexture.Update(textUpdate(ctx.Renderer(), font, color, FmtMoney(b.Flower.BuyPrice))); err != nil { if err := b.priceTexture.Update(textUpdate(ctx.Renderer(), font, color, FmtMoney(b.Flower.BuyPrice))); err != nil {
return err return err
} }
b.Disabled = !b.Flower.Unlocked
b.upToDate = true b.upToDate = true
return nil return nil
} }

View File

@ -5,13 +5,18 @@ import (
"image/color" "image/color"
"log" "log"
"opslag.de/schobers/fs/ricefs"
"opslag.de/schobers/geom" "opslag.de/schobers/geom"
"opslag.de/schobers/zntg" "opslag.de/schobers/zntg"
"opslag.de/schobers/zntg/addons/riceres" "opslag.de/schobers/zntg/addons/res"
"opslag.de/schobers/zntg/play" "opslag.de/schobers/zntg/play"
"opslag.de/schobers/zntg/ui" "opslag.de/schobers/zntg/ui"
_ "opslag.de/schobers/zntg/sdlui" // rendering backend
// _ "opslag.de/schobers/zntg/allg5ui" // rendering backend
rice "github.com/GeertJohan/go.rice" rice "github.com/GeertJohan/go.rice"
"github.com/veandco/go-sdl2/sdl"
"opslag.de/schobers/tins2020" "opslag.de/schobers/tins2020"
) )
@ -26,8 +31,9 @@ func main() {
} }
func openResources(box *rice.Box) ui.Resources { func openResources(box *rice.Box) ui.Resources {
embedded := riceres.New(box) fs := ricefs.NewFs(box)
return ui.NewFallbackResources(ui.NewPathResources(nil, box.Name()), embedded) resources, _ := res.NewAferoFallbackResources(`res`, fs, `botanim`)
return resources
} }
type app struct { type app struct {
@ -134,9 +140,8 @@ func run() error {
} }
defer settings.Store() defer settings.Store()
var location *geom.PointF32 if settings.Window.Location == nil {
if settings.Window.Location != nil { settings.Window.Location = ptPtr(sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED)
location = &geom.PointF32{X: float32(settings.Window.Location.X), Y: float32(settings.Window.Location.Y)}
} }
if settings.Window.Size == nil { if settings.Window.Size == nil {
settings.Window.Size = ptPtr(800, 600) settings.Window.Size = ptPtr(800, 600)
@ -146,7 +151,7 @@ func run() error {
settings.Window.VSync = &vsync settings.Window.VSync = &vsync
} }
renderer, err := ui.NewRenderer("Botanim - TINS 2020", settings.Window.Size.X, settings.Window.Size.Y, ui.NewRendererOptions{ renderer, err := ui.NewRenderer("Botanim - TINS 2020", settings.Window.Size.X, settings.Window.Size.Y, ui.NewRendererOptions{
Location: location, Location: &geom.PointF32{X: float32(settings.Window.Location.X), Y: float32(settings.Window.Location.Y)},
Resizable: true, Resizable: true,
VSync: *settings.Window.VSync, VSync: *settings.Window.VSync,
}) })

View File

@ -1,16 +0,0 @@
// +build allegro
package main
import (
"log"
_ "opslag.de/schobers/zntg/allg5ui" // rendering backend
)
// #cgo windows,allegro LDFLAGS: -Wl,-subsystem,windows
import "C"
func init() {
log.Println("Using Allegro5 rendering backend")
}

View File

@ -1,13 +0,0 @@
// +build !allegro
package main
import (
"log"
_ "opslag.de/schobers/zntg/sdlui" // SDL rendering backend
)
func init() {
log.Println("Using SDL rendering backend")
}

View File

@ -1,140 +0,0 @@
package tins2020
import (
"opslag.de/schobers/geom"
"opslag.de/schobers/zntg"
"opslag.de/schobers/zntg/ui"
)
type confirmationDialog struct {
ui.Proxy
active string
cancel ui.Button
confirm ui.Button
question ui.Paragraph
userDecided ui.Events
}
func newConfirmationDialog(caption, question string) *confirmationDialog {
dialog := &confirmationDialog{}
dialog.active = "confirm"
dialog.cancel.Text = "Cancel"
dialog.cancel.ButtonClicked().AddHandler(func(ctx ui.Context, _ ui.ControlClickedArgs) { dialog.userCanceled(ctx) })
dialog.cancel.Type = ui.ButtonTypeText
dialog.cancel.HoverColor = zntg.MustHexColor(`FFFFFF1F`)
dialog.confirm.Text = "OK"
dialog.confirm.ButtonClicked().AddHandler(func(ctx ui.Context, _ ui.ControlClickedArgs) { dialog.userConfirmed(ctx) })
dialog.confirm.Type = ui.ButtonTypeText
dialog.confirm.HoverColor = zntg.MustHexColor(`FFFFFF1F`)
dialog.question.Text = question
dialog.updateActive()
responses := ui.BuildStackPanel(ui.OrientationHorizontal, func(p *ui.StackPanel) {
p.AddChild(ui.FixedWidth(&dialog.cancel, 160))
p.AddChild(ui.FixedWidth(&dialog.confirm, 160))
})
content := &dialogBase{}
content.Background = zntg.MustHexColor(`#0000007F`)
content.Init(caption, ui.BuildStackPanel(ui.OrientationVertical, func(p *ui.StackPanel) {
p.AddChild(&dialog.question)
p.AddChild(responses)
}))
dialog.Content = ui.Background(ui.BuildSpacing(content, func(s *ui.Spacing) {
s.Width = ui.Fixed(320)
s.Center()
}), zntg.MustHexColor(`#0000007F`))
return dialog
}
func (d *confirmationDialog) toggleActive() {
if d.active == "confirm" {
d.active = "cancel"
} else {
d.active = "confirm"
}
d.updateActive()
}
func (d *confirmationDialog) updateActive() {
d.cancel.Background = nil
d.confirm.Background = nil
switch d.active {
case "cancel":
d.cancel.Background = hoverTransparentColor
case "confirm":
d.confirm.Background = hoverTransparentColor
}
}
func (d *confirmationDialog) userCanceled(ctx ui.Context) {
d.userDecided.Notify(ctx, false)
}
func (d *confirmationDialog) userConfirmed(ctx ui.Context) {
d.userDecided.Notify(ctx, true)
}
func (d *confirmationDialog) Handle(ctx ui.Context, e ui.Event) bool {
if d.Proxy.Handle(ctx, e) {
return true
}
switch e := e.(type) {
case *ui.MouseButtonDownEvent:
if e.Button == ui.MouseButtonRight {
d.userCanceled(ctx)
return true
}
case *ui.MouseMoveEvent:
if d.cancel.IsOver() {
d.active = "cancel"
}
if d.confirm.IsOver() {
d.active = "confirm"
}
d.updateActive()
case *ui.KeyDownEvent:
switch e.Key {
case ui.KeyEscape:
d.userCanceled(ctx)
return true
case ui.KeyEnter:
switch d.active {
case "cancel":
d.userCanceled(ctx)
case "confirm":
d.userConfirmed(ctx)
}
return true
case ui.KeyLeft:
d.toggleActive()
case ui.KeyRight:
d.toggleActive()
case ui.KeyTab:
d.toggleActive()
}
}
return false
}
type dialogBase struct {
ui.StackPanel
caption ui.Label
content ui.Proxy
}
func (d *dialogBase) DesiredSize(ctx ui.Context, size geom.PointF32) geom.PointF32 {
return d.StackPanel.DesiredSize(ctx, size)
}
func (d *dialogBase) Init(caption string, content ui.Control) {
d.caption.Text = caption
d.content.Content = content
d.Children = []ui.Control{&d.caption, &d.content}
}

View File

@ -67,20 +67,6 @@ func (d *Dialogs) Hidden() {
d.Proxy.Hidden() d.Proxy.Hidden()
} }
func (d *Dialogs) AskConfirmation(ctx ui.Context, caption, question string, confirm, cancel ui.EventFn) {
dialog := newConfirmationDialog(caption, question)
dialog.userDecided.AddHandler(func(ctx ui.Context, state interface{}) {
decision := state.(bool)
if decision {
confirm(ctx, nil)
} else {
cancel(ctx, nil)
}
d.Close(ctx)
})
d.showDialog(ctx, dialog)
}
func (d *Dialogs) ShowIntro(ctx ui.Context) { func (d *Dialogs) ShowIntro(ctx ui.Context) {
d.showDialog(ctx, d.intro) d.showDialog(ctx, d.intro)
} }

View File

@ -64,8 +64,8 @@ func NewConeflowerTraits() FlowerTraits {
Spread: 0.0005, Spread: 0.0005,
Life: 0.99993, Life: 0.99993,
Resistance: FlowerResistance{ Resistance: FlowerResistance{
Cold: 0.6, Cold: 0.7,
Hot: 0.9, Hot: 0.8,
Dry: 0.8, Dry: 0.8,
Wet: 0.5, Wet: 0.5,
}, },

View File

@ -261,12 +261,7 @@ func (g *Game) TogglePause(ctx ui.Context) {
} }
} }
func (g *Game) Tool() Tool { func (g *Game) Tool() Tool { return g.tool }
if g.tool == nil {
return &NoTool{}
}
return g.tool
}
func (g *Game) ToolChanged() ui.EventHandler { return &g.toolChanged } func (g *Game) ToolChanged() ui.EventHandler { return &g.toolChanged }

View File

@ -32,36 +32,6 @@ func NewGameControls(game *Game, dialogs *Dialogs) *GameControls {
return &GameControls{game: game, dialogs: dialogs} return &GameControls{game: game, dialogs: dialogs}
} }
func (c *GameControls) askUserBeforeLoad(ctx ui.Context) {
c.dialogs.AskConfirmation(ctx,
"Do you want to load?",
"You will lose any progress you made during this session.",
func(ui.Context, interface{}) {
c.game.Load(ctx)
c.updateFlowerControls()
},
func(ui.Context, interface{}) {})
}
func (c *GameControls) askUserBeforeNew(ctx ui.Context) {
c.dialogs.AskConfirmation(ctx,
"Do you want to start a new game?",
"You will lose any progress you made during this session.",
func(ui.Context, interface{}) {
c.game.New(ctx)
c.updateFlowerControls()
},
func(ui.Context, interface{}) {})
}
func (c *GameControls) askUserBeforeSave(ctx ui.Context) {
c.dialogs.AskConfirmation(ctx,
"Do you want to save?",
"Saving will overwrite any previous save game.",
func(ui.Context, interface{}) { c.game.Save() },
func(ui.Context, interface{}) {})
}
func (c *GameControls) Init(ctx ui.Context) { func (c *GameControls) Init(ctx ui.Context) {
ctx.Overlays().AddOnTop(fpsOverlayName, &play.FPS{}, false) ctx.Overlays().AddOnTop(fpsOverlayName, &play.FPS{}, false)
c.game.SpeedChanged().AddHandler(c.speedChanged) c.game.SpeedChanged().AddHandler(c.speedChanged)
@ -106,17 +76,23 @@ func (c *GameControls) Init(ctx ui.Context) {
b.Disabled = true b.Disabled = true
b.DisabledColor = zntg.MustHexColor("#AFAFAF") b.DisabledColor = zntg.MustHexColor("#AFAFAF")
}), }),
NewIconButtonConfigure("control-save", c.askUserBeforeSave, func(b *IconButton) { NewIconButtonConfigure("control-save", func(ui.Context) { c.game.Save() }, func(b *IconButton) {
b.Tooltip = "Save game (key: Ctrl+S)" b.Tooltip = "Save game (overwrites previous save; no confirmation)"
}), }),
NewIconButtonConfigure("control-load", c.askUserBeforeLoad, func(b *IconButton) { NewIconButtonConfigure("control-load", func(ctx ui.Context) {
b.Tooltip = "Load last saved game (key: Ctrl+L)" c.game.Load(ctx)
c.updateFlowerControls()
}, func(b *IconButton) {
b.Tooltip = "Load last saved game (no confirmation)"
}), }),
NewIconButtonConfigure("control-new", c.askUserBeforeNew, func(b *IconButton) { NewIconButtonConfigure("control-new", func(ctx ui.Context) {
b.Tooltip = "Start new game (key: Ctrl+N)" c.game.New(ctx)
c.updateFlowerControls()
}, func(b *IconButton) {
b.Tooltip = "Start new game (no confirmation)"
}), }),
NewIconButtonConfigure("control-information", c.dialogs.ShowIntro, func(b *IconButton) { NewIconButtonConfigure("control-information", c.dialogs.ShowIntro, func(b *IconButton) {
b.Tooltip = "Show information/intro (key: Escape)" b.Tooltip = "Show information/intro"
}), }),
} }
for i, child := range c.menu.Children { for i, child := range c.menu.Children {
@ -219,7 +195,7 @@ func (c *GameControls) Handle(ctx ui.Context, event ui.Event) bool {
case ui.KeyR: case ui.KeyR:
c.dialogs.ShowResearch(ctx) c.dialogs.ShowResearch(ctx)
case ui.KeyEscape: case ui.KeyEscape:
if c.game.Tool().Type() == "none" { if c.game.Tool() == nil {
c.dialogs.ShowIntro(ctx) c.dialogs.ShowIntro(ctx)
} else { } else {
c.game.CancelTool(ctx) c.game.CancelTool(ctx)
@ -229,16 +205,6 @@ func (c *GameControls) Handle(ctx ui.Context, event ui.Event) bool {
c.game.Debug = !c.game.Debug c.game.Debug = !c.game.Debug
ctx.Overlays().Toggle(fpsOverlayName) ctx.Overlays().Toggle(fpsOverlayName)
} }
if e.Modifiers == ui.KeyModifierControl {
switch e.Key {
case ui.KeyL:
c.askUserBeforeLoad(ctx)
case ui.KeyN:
c.askUserBeforeNew(ctx)
case ui.KeyS:
c.askUserBeforeSave(ctx)
}
}
} }
return false return false
} }

View File

@ -31,7 +31,7 @@ func (h *Herbarium) Reset() {
}) })
h.Add("loosestrife", FlowerDescriptor{ h.Add("loosestrife", FlowerDescriptor{
Name: "Loosestrife", Name: "Loosestrife",
Description: "A simple flower that will spread in temperate and damp climates.", Description: "A simple flower that will spread in temperate and wet climates.",
IconTemplate: "flower-loosestrife-%s", IconTemplate: "flower-loosestrife-%s",
BuyPrice: 100, BuyPrice: 100,
SellPrice: 20, SellPrice: 20,

View File

@ -41,7 +41,7 @@ func (b *IconButton) Render(ctx ui.Context) {
} }
func (b *IconButton) RenderActive(ctx ui.Context) { func (b *IconButton) RenderActive(ctx ui.Context) {
if b.Active || (!b.Disabled && b.IsOver()) { if b.Active && !b.Disabled && !b.IsOver() {
ctx.Renderer().FillRectangle(b.Bounds(), hoverTransparentColor) ctx.Renderer().FillRectangle(b.Bounds(), hoverTransparentColor)
} }
} }

View File

@ -66,7 +66,7 @@ type LargeDialogTitleBar struct {
ui.ContainerBase ui.ContainerBase
title ui.Label title ui.Label
close ui.Button close IconButton
} }
func NewLargeDialogTitleBar(title string, closeRequested ui.EventFn) *LargeDialogTitleBar { func NewLargeDialogTitleBar(title string, closeRequested ui.EventFn) *LargeDialogTitleBar {

43
resources.go Normal file
View File

@ -0,0 +1,43 @@
package tins2020
import (
rice "github.com/GeertJohan/go.rice"
"github.com/spf13/afero"
"opslag.de/schobers/fs/ricefs"
"opslag.de/schobers/fs/vfs"
)
type Resources struct {
box *rice.Box
fs afero.Fs
copy vfs.CopyDir
}
func (r *Resources) Box() *rice.Box {
return r.box
}
func (r *Resources) Destroy() {
r.copy.Destroy()
}
func (r *Resources) Fs() afero.Fs {
return r.fs
}
func (r *Resources) Open(box *rice.Box) error {
r.box = box
r.fs = vfs.NewOsFsFallback(r.box.Name(), ricefs.NewFs(box))
copy, err := vfs.NewCopyDir(r.fs)
if err != nil {
return err
}
r.copy = copy
return nil
}
func (r *Resources) Copy() vfs.CopyDir { return r.copy }
func (r *Resources) Retrieve(name string) (string, error) {
return r.copy.Retrieve(name)
}

View File

@ -7,54 +7,33 @@ else
version="$1" version="$1"
fi fi
version_safe=${version//\./_}
echo "Creating ${version} release" echo "Creating ${version} release"
rm -rf build/linux* rm -rf build/linux*
rm -rf build/macosx*
rm -rf build/windows* rm -rf build/windows*
mkdir -p build/release mkdir -p build/linux
mkdir -p build/windows
go generate ../cmd/tins2020 go generate ../cmd/tins2020
mkdir -p build/linux
go build -tags static -ldflags "-s -w" -o build/linux/botanim ../cmd/tins2020 go build -tags static -ldflags "-s -w" -o build/linux/botanim ../cmd/tins2020
cp ../README.md build/linux cp ../README.md build/linux
cd build/linux
zip -9 -q ../release/botanim_${version_safe}_linux_amd64.zip *
echo "Created Linux release: build/release/botanim_${version_safe}_linux_amd64.zip"
cd ../..
mkdir -p build/linux-allegro
go build -tags static,allegro -ldflags "-s -w" -o build/linux-allegro/botanim ../cmd/tins2020
cp ../README.md build/linux-allegro
cd build/linux-allegro
zip -9 -q ../release/botanim_allegro_${version_safe}_linux_amd64.zip *
echo "Created Linux (Allegro) release: build/release/botanim_allegro_${version_safe}_linux_amd64.zip"
cd ../..
mkdir -p build/macosx
CGO_ENABLED=1 CC=o64-clang CXX=o64-clang++ GOOS=darwin GOARCH=amd64 go build -tags static -ldflags "-s -w" -o build/macosx/botanim ../cmd/tins2020
cp ../README.md build/macosx
cd build/macosx
zip -9 -q ../release/botanim_${version_safe}_macosx_amd64.zip *
echo "Created Mac OS X release: build/release/botanim_${version_safe}_macosx_amd64.zip"
cd ../..
mkdir -p build/windows
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 go build -tags static -ldflags "-s -w" -o build/windows/botanim.exe ../cmd/tins2020 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 go build -tags static -ldflags "-s -w" -o build/windows/botanim.exe ../cmd/tins2020
cp ../README.md build/windows cp ../README.md build/windows
cd build/windows
zip -9 -q ../release/botanim_${version_safe}_windows_amd64.zip *
echo "Created Windows release: build/release/botanim_${version_safe}_windows_amd64.zip"
cd ../..
mkdir -p build/windows-allegro
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 go build -tags static,allegro,mingw64_7_3 -ldflags "-s -w" -o build/windows-allegro/botanim.exe ../cmd/tins2020 mkdir -p build/release
cp ../README.md build/windows-allegro
cd build/windows-allegro cd build
zip -9 -q ../release/botanim_allegro_${version_safe}_windows_amd64.zip *
echo "Created Windows (Allegro) release: build/release/botanim_allegro_${version_safe}_windows_amd64.zip" cd linux
cd ../.. zip -9 -q ../release/botanim_${version}_linux_amd64.zip *
echo "Created Linux release: build/release/botanim_${version}_linux_amd64.zip"
cd ..
cd windows
zip -9 -q ../release/botanim_${version}_windows_amd64.zip *
echo "Created Windows release: build/release/botanim_${version}_windows_amd64.zip"
cd ..

View File

@ -63,10 +63,13 @@ func (r *terrainRenderer) Handle(ctx ui.Context, event ui.Event) bool {
} }
} }
case *ui.MouseButtonUpEvent: case *ui.MouseButtonUpEvent:
pos := e.Pos()
if pos.In(r.interactBounds) {
if _, ok := r.drag.IsDragging(); ok { if _, ok := r.drag.IsDragging(); ok {
r.game.Terrain.Center = r.isometric.TileInt(r.isometric.Center()) r.game.Terrain.Center = r.isometric.TileInt(r.isometric.Center())
r.drag.Cancel() r.drag.Cancel()
} }
}
case *ui.MouseMoveEvent: case *ui.MouseMoveEvent:
pos := e.Pos() pos := e.Pos()
if pos.In(r.interactBounds) { if pos.In(r.interactBounds) {
@ -77,7 +80,7 @@ func (r *terrainRenderer) Handle(ctx ui.Context, event ui.Event) bool {
} }
if _, ok := r.drag.IsDragging(); ok { if _, ok := r.drag.IsDragging(); ok {
delta, _ := r.drag.Move(pos) delta, _ := r.drag.Move(pos)
r.isometric.Pan(delta.Invert()) r.isometric.Pan(r.isometric.ViewToTileRelative(delta.Invert()))
} }
if r.hover != nil { if r.hover != nil {
if e.MouseWheel < 0 { if e.MouseWheel < 0 {
@ -97,54 +100,13 @@ func (r *terrainRenderer) Handle(ctx ui.Context, event ui.Event) bool {
case ui.KeyPadMinus: case ui.KeyPadMinus:
r.isometric.ZoomOut(r.isometric.Center()) r.isometric.ZoomOut(r.isometric.Center())
case ui.KeyW: case ui.KeyW:
r.isometric.PanTile(geom.PtF32(-1, -1)) r.isometric.Pan(geom.PtF32(-1, -1))
case ui.KeyA: case ui.KeyA:
r.isometric.PanTile(geom.PtF32(-1, 1)) r.isometric.Pan(geom.PtF32(-1, 1))
case ui.KeyS: case ui.KeyS:
r.isometric.PanTile(geom.PtF32(1, 1)) r.isometric.Pan(geom.PtF32(1, 1))
case ui.KeyD: case ui.KeyD:
r.isometric.PanTile(geom.PtF32(1, -1)) r.isometric.Pan(geom.PtF32(1, -1))
}
}
tool := r.game.Tool().Type()
if r.hover != nil {
if tool != "none" {
ctx.Renderer().SetMouseCursor(ui.MouseCursorPointer)
}
if tool == "plant-flower" {
terrain := r.game.Terrain
temp := func() string {
temp := terrain.Temp.Value(r.hover.X, r.hover.Y)
switch {
case temp < .3:
return "very cold"
case temp < .4:
return "cold"
case temp > .7:
return "very hot"
case temp > .6:
return "hot"
default:
return "moderate"
}
}()
humid := func() string {
humid := terrain.Humid.Value(r.hover.X, r.hover.Y)
switch {
case humid < .3:
return " and very arid"
case humid < .4:
return " and arid"
case humid > .7:
return " and very damp"
case humid > .6:
return " and damp"
default:
return ""
}
}()
ctx.ShowTooltip(fmt.Sprintf("It is %s%s over here", temp, humid))
} }
} }
return false return false

View File

@ -17,12 +17,6 @@ func (t *PlantFlowerTool) ClickedTile(game *Game, tile geom.Point) {
game.PlantFlower(t.FlowerID, tile) game.PlantFlower(t.FlowerID, tile)
} }
type NoTool struct{}
func (t *NoTool) Type() string { return "none" }
func (t *NoTool) ClickedTile(*Game, geom.Point) {}
type ShovelTool struct{} type ShovelTool struct{}
func (t *ShovelTool) Type() string { return "shovel" } func (t *ShovelTool) Type() string { return "shovel" }