New Doodad: Blue Bird
* The blue bird follows the same base AI as the red bird (it has a target altitude that it tries to maintain, and it will dive at the player) but the blue bird flies in a sine wave pattern around its target altitude. It also has a longer scan radius to search for the player than the red bird. * The sine wave pattern of the blue bird means you may fly under its radar depending how high it is on average. Cheat codes that replace the player character are refactored to make it easier to extend, and new cheats have been added: * super azulian: play as the Red Azulian. * hyper azulian: play as the White Azulian. * bluebird: play as the new Bird (blue).
|
@ -2,9 +2,17 @@ ALL: build
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
doodad convert -t "Bird (red)" left-1.png left-2.png right-1.png right-2.png \
|
doodad convert -t "Bird (red)" red/left-1.png red/left-2.png red/right-1.png \
|
||||||
dive-left.png dive-right.png bird-red.doodad
|
red/right-2.png red/dive-left.png red/dive-right.png \
|
||||||
|
bird-red.doodad
|
||||||
doodad install-script bird.js bird-red.doodad
|
doodad install-script bird.js bird-red.doodad
|
||||||
|
doodad edit-doodad --tag "color=red" bird-red.doodad
|
||||||
|
|
||||||
|
doodad convert -t "Bird (blue)" blue/left-1.png blue/left-2.png blue/right-1.png \
|
||||||
|
blue/right-2.png blue/dive-left.png blue/dive-right.png \
|
||||||
|
bird-blue.doodad
|
||||||
|
doodad install-script bird.js bird-blue.doodad
|
||||||
|
doodad edit-doodad --tag "color=blue" bird-blue.doodad
|
||||||
|
|
||||||
# Tag the category for these doodads
|
# Tag the category for these doodads
|
||||||
for i in *.doodad; do\
|
for i in *.doodad; do\
|
||||||
|
|
|
@ -1,8 +1,26 @@
|
||||||
// Bird
|
// Bird (red and blue)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Base A.I. behaviors (red bird) are:
|
||||||
|
|
||||||
|
- Tries to maintain original altitude in level and flies left/right.
|
||||||
|
- Divebombs the player to attack, then climbs back to its original
|
||||||
|
altitude when it hits a floor/wall.
|
||||||
|
|
||||||
|
Blue bird:
|
||||||
|
|
||||||
|
- Flies in a sine wave pattern (its target altitude fluctuates
|
||||||
|
around the bird's original placement on the level).
|
||||||
|
- Longer aggro radius to dive.
|
||||||
|
*/
|
||||||
|
|
||||||
let speed = 4,
|
let speed = 4,
|
||||||
Vx = Vy = 0,
|
Vx = Vy = 0,
|
||||||
altitude = Self.Position().Y; // original height in the level
|
color = Self.GetTag("color"), // informs our A.I. behaviors
|
||||||
|
searchStep = 12 // pixels to step while searching for a player
|
||||||
|
searchLimit = color === "blue" ? 24 : 12, // multiples of searchStep for aggro radius
|
||||||
|
altitude = Self.Position().Y, // original height in level
|
||||||
|
targetAltitude = altitude; // bird's target height to maintain
|
||||||
|
|
||||||
let direction = "left",
|
let direction = "left",
|
||||||
lastDirection = "left";
|
lastDirection = "left";
|
||||||
|
@ -42,6 +60,11 @@ function main() {
|
||||||
lastSampled = Point(0, 0);
|
lastSampled = Point(0, 0);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
// Run blue bird A.I. if we are blue: moves our target altitude along a sine wave.
|
||||||
|
if (color === "blue") {
|
||||||
|
AI_BlueBird();
|
||||||
|
}
|
||||||
|
|
||||||
// Sample how far we've moved to detect hitting a wall.
|
// Sample how far we've moved to detect hitting a wall.
|
||||||
if (sampleTick % sampleRate === 0) {
|
if (sampleTick % sampleRate === 0) {
|
||||||
let curP = Self.Position()
|
let curP = Self.Position()
|
||||||
|
@ -65,8 +88,8 @@ function main() {
|
||||||
// If we are not flying at our original altitude, correct for that.
|
// If we are not flying at our original altitude, correct for that.
|
||||||
let curV = Self.Position();
|
let curV = Self.Position();
|
||||||
Vy = 0.0;
|
Vy = 0.0;
|
||||||
if (curV.Y != altitude) {
|
if (curV.Y != targetAltitude) {
|
||||||
Vy = curV.Y < altitude ? 1 : -1;
|
Vy = curV.Y < targetAltitude ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan for the player character and dive.
|
// Scan for the player character and dive.
|
||||||
|
@ -114,9 +137,9 @@ function AI_ScanForPlayer() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let stepY = 12, // number of pixels to skip
|
let stepY = searchStep, // number of pixels to skip
|
||||||
stepX = stepY,
|
stepX = stepY,
|
||||||
limit = stepX * 20, // furthest we'll scan
|
limit = stepX * searchLimit, // furthest we'll scan
|
||||||
scanX = scanY = 0,
|
scanX = scanY = 0,
|
||||||
size = Self.Size(),
|
size = Self.Size(),
|
||||||
fromPoint = Self.Position();
|
fromPoint = Self.Position();
|
||||||
|
@ -147,6 +170,35 @@ function AI_ScanForPlayer() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sine wave controls for the Blue bird.
|
||||||
|
var sineLimit = 32,
|
||||||
|
sineCounter = 0,
|
||||||
|
sineDirection = 1,
|
||||||
|
sineFrequency = 5, // every 500ms
|
||||||
|
sineStep = 16;
|
||||||
|
|
||||||
|
// A.I. Subroutine: sine wave pattern (Blue bird).
|
||||||
|
function AI_BlueBird() {
|
||||||
|
// The main loop runs on a 100ms interval, control how frequently
|
||||||
|
// to adjust the bird's target velocity.
|
||||||
|
if (sineCounter > 0 && (sineCounter % sineFrequency) > 1) {
|
||||||
|
sineCounter++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sineCounter++;
|
||||||
|
|
||||||
|
targetAltitude += sineStep*sineDirection;
|
||||||
|
|
||||||
|
// Cap the distance between our starting altitude and sine-wave target.
|
||||||
|
if (targetAltitude < altitude && altitude - targetAltitude >= sineLimit) {
|
||||||
|
targetAltitude = altitude - sineLimit;
|
||||||
|
sineDirection = 1
|
||||||
|
} else if (targetAltitude > altitude && targetAltitude - altitude >= sineLimit) {
|
||||||
|
targetAltitude = altitude + sineLimit;
|
||||||
|
sineDirection = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If under control of the player character.
|
// If under control of the player character.
|
||||||
function player() {
|
function player() {
|
||||||
let playerSpeed = 12,
|
let playerSpeed = 12,
|
||||||
|
|
BIN
dev-assets/doodads/bird/blue/dive-left.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dev-assets/doodads/bird/blue/dive-right.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dev-assets/doodads/bird/blue/left-1.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
dev-assets/doodads/bird/blue/left-2.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
dev-assets/doodads/bird/blue/right-1.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
dev-assets/doodads/bird/blue/right-2.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 959 B After Width: | Height: | Size: 959 B |
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 989 B |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1022 B After Width: | Height: | Size: 1022 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
@ -26,10 +26,6 @@ var (
|
||||||
CheatGiveKeys = "give all keys"
|
CheatGiveKeys = "give all keys"
|
||||||
CheatDropItems = "drop all items"
|
CheatDropItems = "drop all items"
|
||||||
CheatPlayAsBird = "fly like a bird"
|
CheatPlayAsBird = "fly like a bird"
|
||||||
CheatPlayAsBoy = "pinocchio"
|
|
||||||
CheatPlayAsAzuBlue = "the cell"
|
|
||||||
CheatPlayAsThief = "play as thief"
|
|
||||||
CheatPlayAsAnvil = "megaton weight"
|
|
||||||
CheatGodMode = "god mode"
|
CheatGodMode = "god mode"
|
||||||
CheatDebugLoadScreen = "test load screen"
|
CheatDebugLoadScreen = "test load screen"
|
||||||
CheatUnlockLevels = "master key"
|
CheatUnlockLevels = "master key"
|
||||||
|
@ -39,3 +35,15 @@ var (
|
||||||
var (
|
var (
|
||||||
CheatEnabledUnlockLevels bool
|
CheatEnabledUnlockLevels bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Actor replacement cheats
|
||||||
|
var CheatActors = map[string]string{
|
||||||
|
"pinocchio": "boy",
|
||||||
|
"the cell": "azu-blue",
|
||||||
|
"super azulian": "azu-red",
|
||||||
|
"hyper azulian": "azu-white",
|
||||||
|
"fly like a bird": "bird-red",
|
||||||
|
"bluebird": "bird-blue",
|
||||||
|
"megaton weight": "anvil",
|
||||||
|
"play as thief": "thief",
|
||||||
|
}
|
||||||
|
|
|
@ -131,8 +131,8 @@ var (
|
||||||
EagerRenderLevelChunks = true
|
EagerRenderLevelChunks = true
|
||||||
|
|
||||||
// Number of chunks margin outside the Canvas Viewport for the LoadingViewport.
|
// Number of chunks margin outside the Canvas Viewport for the LoadingViewport.
|
||||||
LoadingViewportMarginChunks = 2
|
LoadingViewportMarginChunks = render.NewPoint(8, 4) // hoz, vert
|
||||||
CanvasLoadUnloadModuloTicks uint64 = 4
|
CanvasLoadUnloadModuloTicks uint64 = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// Edit Mode Values
|
// Edit Mode Values
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package doodle
|
package doodle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/pkg/balance"
|
"git.kirsle.net/apps/doodle/pkg/balance"
|
||||||
|
@ -134,26 +135,6 @@ func (c Command) cheatCommand(d *Doodle) bool {
|
||||||
setPlayerCharacter = true
|
setPlayerCharacter = true
|
||||||
d.Flash("Set default player character to Bird (red)")
|
d.Flash("Set default player character to Bird (red)")
|
||||||
|
|
||||||
case balance.CheatPlayAsBoy:
|
|
||||||
balance.PlayerCharacterDoodad = "boy.doodad"
|
|
||||||
setPlayerCharacter = true
|
|
||||||
d.Flash("Set default player character to Boy")
|
|
||||||
|
|
||||||
case balance.CheatPlayAsAzuBlue:
|
|
||||||
balance.PlayerCharacterDoodad = "azu-blu.doodad"
|
|
||||||
setPlayerCharacter = true
|
|
||||||
d.Flash("Set default player character to Blue Azulian")
|
|
||||||
|
|
||||||
case balance.CheatPlayAsThief:
|
|
||||||
balance.PlayerCharacterDoodad = "thief.doodad"
|
|
||||||
setPlayerCharacter = true
|
|
||||||
d.Flash("Set default player character to Thief")
|
|
||||||
|
|
||||||
case balance.CheatPlayAsAnvil:
|
|
||||||
balance.PlayerCharacterDoodad = "anvil.doodad"
|
|
||||||
setPlayerCharacter = true
|
|
||||||
d.Flash("Set default player character to the Anvil")
|
|
||||||
|
|
||||||
case balance.CheatGodMode:
|
case balance.CheatGodMode:
|
||||||
if isPlay {
|
if isPlay {
|
||||||
d.Flash("God mode toggled")
|
d.Flash("God mode toggled")
|
||||||
|
@ -189,7 +170,15 @@ func (c Command) cheatCommand(d *Doodle) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false
|
// See if it was an endorsed actor cheat.
|
||||||
|
if filename, ok := balance.CheatActors[strings.ToLower(c.Raw)]; ok {
|
||||||
|
d.Flash("Set default player character to %s.", filename)
|
||||||
|
balance.PlayerCharacterDoodad = filename
|
||||||
|
setPlayerCharacter = true
|
||||||
|
} else {
|
||||||
|
// Not a cheat code.
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're setting the player character and in Play Mode, do it.
|
// If we're setting the player character and in Play Mode, do it.
|
||||||
|
|
|
@ -20,7 +20,7 @@ type Chunker struct {
|
||||||
// Layer is optional for the caller, levels use only 0 and
|
// Layer is optional for the caller, levels use only 0 and
|
||||||
// doodads use them for frames. When chunks are exported to
|
// doodads use them for frames. When chunks are exported to
|
||||||
// zipfile the Layer keeps them from overlapping.
|
// zipfile the Layer keeps them from overlapping.
|
||||||
Layer int
|
Layer int `json:"-"` // internal use only
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
|
|
||||||
// A Zipfile reference for new-style levels and doodads which
|
// A Zipfile reference for new-style levels and doodads which
|
||||||
|
@ -311,7 +311,7 @@ func (c *Chunker) GetChunk(p render.Point) (*Chunk, bool) {
|
||||||
// Hit the zipfile for it.
|
// Hit the zipfile for it.
|
||||||
if c.Zipfile != nil {
|
if c.Zipfile != nil {
|
||||||
if chunk, err := ChunkFromZipfile(c.Zipfile, c.Layer, p); err == nil {
|
if chunk, err := ChunkFromZipfile(c.Zipfile, c.Layer, p); err == nil {
|
||||||
log.Debug("GetChunk(%s) cache miss, read from zip", p)
|
// log.Debug("GetChunk(%s) cache miss, read from zip", p)
|
||||||
c.SetChunk(p, chunk) // cache it
|
c.SetChunk(p, chunk) // cache it
|
||||||
c.logChunkAccess(p, chunk) // for the LRU cache
|
c.logChunkAccess(p, chunk) // for the LRU cache
|
||||||
if c.pal != nil {
|
if c.pal != nil {
|
||||||
|
|
|
@ -35,7 +35,7 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-vm.stop:
|
case <-vm.stop:
|
||||||
log.Info("JavaScript VM %s stopping PubSub goroutine", vm.Name)
|
log.Debug("JavaScript VM %s stopping PubSub goroutine", vm.Name)
|
||||||
return
|
return
|
||||||
case msg := <-vm.Inbound:
|
case msg := <-vm.Inbound:
|
||||||
vm.muSubscribe.Lock()
|
vm.muSubscribe.Lock()
|
||||||
|
|
|
@ -377,10 +377,10 @@ func (w *Canvas) LoadingViewport() render.Rect {
|
||||||
}
|
}
|
||||||
|
|
||||||
return render.Rect{
|
return render.Rect{
|
||||||
X: vp.X - chunkSize*margin,
|
X: vp.X - chunkSize*margin.X,
|
||||||
Y: vp.Y - chunkSize*margin,
|
Y: vp.Y - chunkSize*margin.Y,
|
||||||
W: vp.W + chunkSize*margin,
|
W: vp.W + chunkSize*margin.X,
|
||||||
H: vp.H + chunkSize*margin,
|
H: vp.H + chunkSize*margin.Y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -132,6 +132,7 @@ func (c Settings) makeOptionsTab(tabFrame *ui.TabFrame, Width, Height int) *ui.F
|
||||||
Label: "Hide touchscreen control hints during Play Mode",
|
Label: "Hide touchscreen control hints during Play Mode",
|
||||||
Font: balance.UIFont,
|
Font: balance.UIFont,
|
||||||
BoolVariable: c.HideTouchHints,
|
BoolVariable: c.HideTouchHints,
|
||||||
|
OnClick: onClick,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Label: "Level & Doodad Editor",
|
Label: "Level & Doodad Editor",
|
||||||
|
@ -141,6 +142,7 @@ func (c Settings) makeOptionsTab(tabFrame *ui.TabFrame, Width, Height int) *ui.F
|
||||||
Label: "Horizontal instead of vertical toolbars",
|
Label: "Horizontal instead of vertical toolbars",
|
||||||
Font: balance.UIFont,
|
Font: balance.UIFont,
|
||||||
BoolVariable: c.HorizontalToolbars,
|
BoolVariable: c.HorizontalToolbars,
|
||||||
|
OnClick: onClick,
|
||||||
Tooltip: ui.Tooltip{
|
Tooltip: ui.Tooltip{
|
||||||
Text: "Note: reload your level after changing this option.\n" +
|
Text: "Note: reload your level after changing this option.\n" +
|
||||||
"Playtesting and returning will do.",
|
"Playtesting and returning will do.",
|
||||||
|
@ -151,6 +153,7 @@ func (c Settings) makeOptionsTab(tabFrame *ui.TabFrame, Width, Height int) *ui.F
|
||||||
Label: "Disable auto-save in the Editor",
|
Label: "Disable auto-save in the Editor",
|
||||||
Font: balance.UIFont,
|
Font: balance.UIFont,
|
||||||
BoolVariable: c.DisableAutosave,
|
BoolVariable: c.DisableAutosave,
|
||||||
|
OnClick: onClick,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Label: "Draw a crosshair at the mouse cursor.",
|
Label: "Draw a crosshair at the mouse cursor.",
|
||||||
|
@ -409,109 +412,47 @@ func (c Settings) makeExperimentalTab(tabFrame *ui.TabFrame, Width, Height int)
|
||||||
|
|
||||||
// Common click handler for all settings,
|
// Common click handler for all settings,
|
||||||
// so we can write the updated info to disk.
|
// so we can write the updated info to disk.
|
||||||
onClick := func(ed ui.EventData) error {
|
onClick := func() {
|
||||||
saveGameSettings()
|
saveGameSettings()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := []struct {
|
form := magicform.Form{
|
||||||
Header string
|
Supervisor: c.Supervisor,
|
||||||
Text string
|
Engine: c.Engine,
|
||||||
Boolean *bool
|
Vertical: true,
|
||||||
TextVariable *string
|
LabelWidth: 150,
|
||||||
PadY int
|
}
|
||||||
PadX int
|
form.Create(tab, []magicform.Field{
|
||||||
name string // for special cases
|
|
||||||
}{
|
|
||||||
{
|
{
|
||||||
Header: "Enable Experimental Features",
|
Label: "Enable Experimental Features",
|
||||||
|
Font: balance.LabelFont,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Text: "The setting below can enable experimental features in this\n" +
|
Label: "The setting below can enable experimental features in this\n" +
|
||||||
"game. These are features which are still in development and\n" +
|
"game. These are features which are still in development and\n" +
|
||||||
"may have unstable or buggy behavior.",
|
"may have unstable or buggy behavior.",
|
||||||
PadY: 2,
|
Font: balance.UIFont,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Viewport window",
|
Label: "Viewport window",
|
||||||
|
Font: balance.LabelFont,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Text: "This option in the Level menu opens another view into\n" +
|
Label: "This option in the Level menu opens another view into\n" +
|
||||||
"the level. Has glitchy wallpaper problems.",
|
"the level. Has glitchy wallpaper problems.",
|
||||||
PadY: 2,
|
Font: balance.UIFont,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Boolean: c.EnableFeatures,
|
BoolVariable: c.EnableFeatures,
|
||||||
Text: "Enable experimental features",
|
Label: "Enable experimental features",
|
||||||
PadX: 4,
|
Font: balance.UIFont,
|
||||||
|
OnClick: onClick,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Text: "Restart the game for changes to take effect.",
|
Label: "Restart the game for changes to take effect.",
|
||||||
PadY: 2,
|
Font: balance.UIFont,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
for _, row := range rows {
|
|
||||||
row := row
|
|
||||||
frame := ui.NewFrame("Frame")
|
|
||||||
tab.Pack(frame, ui.Pack{
|
|
||||||
Side: ui.N,
|
|
||||||
FillX: true,
|
|
||||||
PadY: row.PadY,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Headers get their own row to themselves.
|
|
||||||
if row.Header != "" {
|
|
||||||
label := ui.NewLabel(ui.Label{
|
|
||||||
Text: row.Header,
|
|
||||||
Font: balance.LabelFont,
|
|
||||||
})
|
|
||||||
frame.Pack(label, ui.Pack{
|
|
||||||
Side: ui.W,
|
|
||||||
PadX: row.PadX,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checkboxes get their own row.
|
|
||||||
if row.Boolean != nil {
|
|
||||||
cb := ui.NewCheckbox(row.Text, row.Boolean, ui.NewLabel(ui.Label{
|
|
||||||
Text: row.Text,
|
|
||||||
Font: balance.UIFont,
|
|
||||||
}))
|
|
||||||
cb.Handle(ui.Click, onClick)
|
|
||||||
cb.Supervise(c.Supervisor)
|
|
||||||
|
|
||||||
// Add warning to the toolbars option if the EditMode is currently active.
|
|
||||||
if row.name == "toolbars" && c.SceneName == "Edit" {
|
|
||||||
ui.NewTooltip(cb, ui.Tooltip{
|
|
||||||
Text: "Note: reload your level after changing this option.\n" +
|
|
||||||
"Playtesting and returning will do.",
|
|
||||||
Edge: ui.Top,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.Pack(cb, ui.Pack{
|
|
||||||
Side: ui.W,
|
|
||||||
PadX: row.PadX,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any leftover Text gets packed to the left.
|
|
||||||
if row.Text != "" {
|
|
||||||
tf := ui.NewFrame("TextFrame")
|
|
||||||
label := ui.NewLabel(ui.Label{
|
|
||||||
Text: row.Text,
|
|
||||||
Font: balance.UIFont,
|
|
||||||
})
|
|
||||||
tf.Pack(label, ui.Pack{
|
|
||||||
Side: ui.W,
|
|
||||||
})
|
|
||||||
frame.Pack(tf, ui.Pack{
|
|
||||||
Side: ui.W,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tab
|
return tab
|
||||||
}
|
}
|
||||||
|
|