Browse Source

Add music and support resetting the game

main
Trevor Slocum 4 weeks ago
parent
commit
eb09ea1ba7
  1. 5
      README.md
  2. 37
      asset/asset.go
  3. BIN
      asset/sound/level_music.ogg
  4. 1
      component/creepbullet.go
  5. 4
      flags.go
  6. 66
      game/game.go
  7. 6
      go.mod
  8. 10
      go.sum
  9. 6
      main.go
  10. 2
      system/camera.go
  11. 2
      system/creep.go
  12. 16
      system/input_move.go
  13. 18
      system/movement.go
  14. 2
      system/rail.go
  15. 100
      system/rendermessage.go
  16. 65
      world/world.go

5
README.md

@ -25,6 +25,11 @@ Run `~/go/bin/brownboxbatman` to play.
Please share issues and suggestions [here](https://code.rocketnine.space/tslocum/brownboxbatman/issues).
## Credits
- [Trevor Slocum](https://rocketnine.space) - Game design and programming
- [node punk](https://open.spotify.com/artist/15eFpWQPNRxB89PnFNWvjU?si=z-jfVwYHTxugaC-BGZiyNg) - Music
## Dependencies
- [ebiten](https://github.com/hajimehoshi/ebiten) - Game engine

37
asset/asset.go

@ -1,16 +1,16 @@
package asset
import (
"bytes"
"embed"
"image"
"image/color"
_ "image/png"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/vorbis"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
const sampleRate = 44100
@ -38,6 +38,8 @@ var (
SoundBatHit2 *audio.Player
SoundBatHit3 *audio.Player
SoundBatHit4 *audio.Player
SoundLevelMusic *audio.Player
)
func init() {
@ -50,6 +52,10 @@ func LoadSounds(ctx *audio.Context) {
SoundBatHit2 = LoadWAV(ctx, "sound/bat_hit/hit2.wav")
SoundBatHit3 = LoadWAV(ctx, "sound/bat_hit/hit3.wav")
SoundBatHit4 = LoadWAV(ctx, "sound/bonk.wav")
SoundLevelMusic = LoadOGG(ctx, "sound/level_music.ogg")
SoundLevelMusic.SetVolume(0.5)
}
func LoadImage(p string) *ebiten.Image {
@ -101,3 +107,26 @@ func LoadWAV(context *audio.Context, p string) *audio.Player {
return player
}
func LoadOGG(context *audio.Context, p string) *audio.Player {
b := LoadBytes(p)
stream, err := vorbis.DecodeWithSampleRate(sampleRate, bytes.NewReader(b))
if err != nil {
panic(err)
}
player, err := context.NewPlayer(audio.NewInfiniteLoop(stream, stream.Length()))
if err != nil {
panic(err)
}
// Workaround to prevent delays when playing for the first time.
player.SetVolume(0)
player.Play()
player.Pause()
player.Rewind()
player.SetVolume(1)
return player
}

BIN
asset/sound/level_music.ogg

Binary file not shown.

1
component/creepbullet.go

@ -6,6 +6,7 @@ import (
)
type CreepBulletComponent struct {
Invulnerable bool // Invulnerable to hazards
}
var CreepBulletComponentID = ECS.NewComponentID()

4
flags.go

@ -25,8 +25,8 @@ func parseFlags() {
ebiten.SetFullscreen(true)
}
if noSplash {
world.World.GameStarted = true
if noSplash || world.World.Debug > 0 {
world.StartGame()
//world.World.MessageVisible = false
}
}

66
game/game.go

@ -9,14 +9,12 @@ import (
"sync"
"time"
"code.rocketnine.space/tslocum/brownboxbatman/entity"
"code.rocketnine.space/tslocum/brownboxbatman/asset"
"code.rocketnine.space/tslocum/brownboxbatman/component"
. "code.rocketnine.space/tslocum/brownboxbatman/ecs"
"code.rocketnine.space/tslocum/brownboxbatman/entity"
"code.rocketnine.space/tslocum/brownboxbatman/system"
"code.rocketnine.space/tslocum/brownboxbatman/world"
"code.rocketnine.space/tslocum/gohan"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/audio"
"golang.org/x/text/language"
@ -51,8 +49,6 @@ const sampleRate = 44100
type game struct {
w, h int
player gohan.Entity
audioContext *audio.Context
op *ebiten.DrawImageOptions
@ -65,6 +61,8 @@ type game struct {
movementSystem *system.MovementSystem
renderSystem *system.RenderSystem
addedSystems bool
sync.Mutex
}
@ -78,21 +76,6 @@ func NewGame() (*game, error) {
const numEntities = 30000
ECS.Preallocate(numEntities)
g.changeMap("map/m1.tmx")
g.addSystems()
err := g.loadAssets()
if err != nil {
return nil, err
}
asset.ImgWhiteSquare.Fill(color.White)
asset.LoadSounds(g.audioContext)
rand.Seed(time.Now().UnixNano())
return g, nil
}
@ -105,7 +88,6 @@ func (g *game) changeMap(filePath string) {
if world.World.Player == 0 {
world.World.Player = entity.NewPlayer()
g.player = world.World.Player
}
const playerStartOffset = 128
@ -114,7 +96,7 @@ func (g *game) changeMap(filePath string) {
w := float64(world.World.Map.Width * world.World.Map.TileWidth)
h := float64(world.World.Map.Height * world.World.Map.TileHeight)
position := ECS.Component(g.player, component.PositionComponentID).(*component.PositionComponent)
position := ECS.Component(world.World.Player, component.PositionComponentID).(*component.PositionComponent)
position.X, position.Y = w/2, h-playerStartOffset
world.World.CamX, world.World.CamY = 0, h-camStartOffset
@ -141,6 +123,32 @@ func (g *game) Update() error {
return nil
}
if world.World.ResetGame {
world.Reset()
g.changeMap("map/m1.tmx")
if !g.addedSystems {
g.addSystems()
err := g.loadAssets()
if err != nil {
return err
}
asset.ImgWhiteSquare.Fill(color.White)
asset.LoadSounds(g.audioContext)
g.addedSystems = true // TODO
}
rand.Seed(time.Now().UnixNano())
world.World.ResetGame = false
world.World.GameOver = false
}
err := ECS.Update()
if err != nil {
return err
@ -160,7 +168,7 @@ func (g *game) addSystems() {
g.movementSystem = system.NewMovementSystem()
ecs.AddSystem(system.NewPlayerMoveSystem(g.player, g.movementSystem))
ecs.AddSystem(system.NewPlayerMoveSystem(world.World.Player, g.movementSystem))
ecs.AddSystem(system.NewplayerFireSystem())
ecs.AddSystem(g.movementSystem)
@ -169,19 +177,21 @@ func (g *game) addSystems() {
ecs.AddSystem(system.NewCameraSystem())
ecs.AddSystem(system.NewRailSystem())
/*ecs.AddSystem(system.NewFireWeaponSystem(g.player))
/*ecs.AddSystem(system.NewFireWeaponSystem(world.World.Player))
ecs.AddSystem(system.NewRenderBackgroundSystem())*/
g.renderSystem = system.NewRenderSystem()
ecs.AddSystem(g.renderSystem)
/*g.messageSystem = system.NewRenderMessageSystem(g.player)
/*g.messageSystem = system.NewRenderMessageSystem(world.World.Player)
ecs.AddSystem(g.messageSystem)*/
ecs.AddSystem(system.NewRenderDebugTextSystem(g.player))
ecs.AddSystem(system.NewRenderMessageSystem())
ecs.AddSystem(system.NewRenderDebugTextSystem(world.World.Player))
ecs.AddSystem(system.NewProfileSystem(g.player))
ecs.AddSystem(system.NewProfileSystem(world.World.Player))
// TODO
/*
@ -195,7 +205,7 @@ func (g *game) loadAssets() error {
}
func (g *game) WarpTo(x, y float64) {
position := ECS.Component(g.player, component.PositionComponentID).(*component.PositionComponent)
position := ECS.Component(world.World.Player, component.PositionComponentID).(*component.PositionComponent)
position.X, position.Y = x, y
log.Printf("Warped to %.2f,%.2f", x, y)
}

6
go.mod

@ -3,7 +3,7 @@ module code.rocketnine.space/tslocum/brownboxbatman
go 1.17
require (
code.rocketnine.space/tslocum/gohan v0.0.0-20211212050415-e08cfe7970d8
code.rocketnine.space/tslocum/gohan v0.0.0-20211229205912-263cd48bca66
github.com/hajimehoshi/ebiten/v2 v2.2.3
github.com/lafriks/go-tiled v0.6.0
golang.org/x/text v0.3.7
@ -11,8 +11,10 @@ require (
require (
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20211213063430-748e38ca8aec // indirect
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.4 // indirect
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.5 // indirect
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 // indirect
github.com/jfreymuth/oggvorbis v1.0.3 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
golang.org/x/exp v0.0.0-20211221223016-e29036178569 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee // indirect

10
go.sum

@ -1,5 +1,5 @@
code.rocketnine.space/tslocum/gohan v0.0.0-20211212050415-e08cfe7970d8 h1:tsIId//EUkKtk0v2wNEv6qUmS2yDnOTd7cmXhQemDyE=
code.rocketnine.space/tslocum/gohan v0.0.0-20211212050415-e08cfe7970d8/go.mod h1:nOvFBFvFPl5sDtkMy2Fn/7QZcWq5RE98/mK+INLqIWg=
code.rocketnine.space/tslocum/gohan v0.0.0-20211229205912-263cd48bca66 h1:H8rapZ2HCQZH+DpcXRZh52kUcXDzgHlJwsZgEGqezpo=
code.rocketnine.space/tslocum/gohan v0.0.0-20211229205912-263cd48bca66/go.mod h1:nOvFBFvFPl5sDtkMy2Fn/7QZcWq5RE98/mK+INLqIWg=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -14,12 +14,14 @@ github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod
github.com/hajimehoshi/go-mp3 v0.3.2/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.2/go.mod h1:rUKQmwMkqmRxe+IAof9+tuYA2ofm8cAWXFmSfzDN8vQ=
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.4 h1:6NIzk6tIJIOUB7mB00FtE5pz0Yt9LDBPcGirBIteJsI=
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.4/go.mod h1:rUKQmwMkqmRxe+IAof9+tuYA2ofm8cAWXFmSfzDN8vQ=
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.5 h1:AwLKf51fpOTVIBxgQUvNokmj/IaYMYsqJQBh6wif1c8=
github.com/hajimehoshi/oto/v2 v2.1.0-alpha.5/go.mod h1:rUKQmwMkqmRxe+IAof9+tuYA2ofm8cAWXFmSfzDN8vQ=
github.com/jakecoffman/cp v1.1.0/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg=
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4=
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
github.com/jfreymuth/oggvorbis v1.0.3 h1:MLNGGyhOMiVcvea9Dp5+gbs2SAwqwQbtrWnonYa0M0Y=
github.com/jfreymuth/oggvorbis v1.0.3/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/lafriks/go-tiled v0.6.0 h1:kDJSvRPep6/5dtfdu0hqiAhXL40HmciMEXRb1TN/AU0=
github.com/lafriks/go-tiled v0.6.0/go.mod h1:xy+4iO8AKWpFNBWeqBqnq+Cb3Oirm5oin/irP/jPx6A=

6
main.go

@ -6,6 +6,8 @@ import (
"os/signal"
"syscall"
"code.rocketnine.space/tslocum/brownboxbatman/world"
"code.rocketnine.space/tslocum/brownboxbatman/game"
"github.com/hajimehoshi/ebiten/v2"
)
@ -27,6 +29,10 @@ func main() {
parseFlags()
if world.World.Debug == 0 {
world.SetMessage("MOVE: ARROW KEYS\nFIRE: Z KEY\nMUTE: M KEY", 144*4)
}
sigc := make(chan os.Signal, 1)
signal.Notify(sigc,
syscall.SIGINT,

2
system/camera.go

@ -29,7 +29,7 @@ func (_ *CameraSystem) Uses() []gohan.ComponentID {
}
func (s *CameraSystem) Update(ctx *gohan.Context) error {
if world.World.MessageVisible || !world.World.GameStarted || world.World.GameOver {
if !world.World.GameStarted || world.World.GameOver {
return nil
}

2
system/creep.go

@ -31,7 +31,7 @@ func (_ *CreepSystem) Uses() []gohan.ComponentID {
}
func (s *CreepSystem) Update(ctx *gohan.Context) error {
if world.World.MessageVisible || !world.World.GameStarted {
if !world.World.GameStarted {
return nil
}

16
system/input_move.go

@ -3,6 +3,8 @@ package system
import (
"os"
"code.rocketnine.space/tslocum/brownboxbatman/asset"
"code.rocketnine.space/tslocum/brownboxbatman/component"
"code.rocketnine.space/tslocum/brownboxbatman/world"
"code.rocketnine.space/tslocum/gohan"
@ -70,14 +72,22 @@ func (s *playerMoveSystem) Update(ctx *gohan.Context) error {
if !world.World.GameStarted {
if ebiten.IsKeyPressed(ebiten.KeyEnter) || ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
world.World.GameStarted = true
world.StartGame()
}
return nil
}
if world.World.MessageVisible {
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
if asset.SoundLevelMusic.IsPlaying() {
asset.SoundLevelMusic.Pause()
} else {
asset.SoundLevelMusic.Play()
}
}
if world.World.GameOver {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
world.World.MessageVisible = false
world.World.ResetGame = true
}
return nil
}

18
system/movement.go

@ -59,7 +59,7 @@ func (_ *MovementSystem) Uses() []gohan.ComponentID {
}
func (s *MovementSystem) Update(ctx *gohan.Context) error {
if !world.World.GameStarted || world.World.MessageVisible {
if !world.World.GameStarted {
return nil
}
@ -78,6 +78,7 @@ func (s *MovementSystem) Update(ctx *gohan.Context) error {
position.X, position.Y = position.X+vx, position.Y+vy
// Force player to remain within the screen bounds.
// TODO same for bullets
if ctx.Entity == world.World.Player {
screenX, screenY := s.levelCoordinatesToScreen(position.X, position.Y)
if screenX < 0 {
@ -125,10 +126,17 @@ func (s *MovementSystem) Update(ctx *gohan.Context) error {
// Check hazard collisions.
if creepBullet != nil || playerBullet != nil {
for _, hazardRect := range world.World.HazardRects {
if bulletRect.Overlaps(hazardRect) {
ctx.RemoveEntity()
return nil
var invulnerable bool
if creepBullet != nil {
b := creepBullet.(*component.CreepBulletComponent)
invulnerable = b.Invulnerable
}
if !invulnerable {
for _, hazardRect := range world.World.HazardRects {
if bulletRect.Overlaps(hazardRect) {
ctx.RemoveEntity()
return nil
}
}
}
}

2
system/rail.go

@ -27,7 +27,7 @@ func (_ *RailSystem) Uses() []gohan.ComponentID {
}
func (s *RailSystem) Update(ctx *gohan.Context) error {
if !world.World.GameStarted || world.World.MessageVisible || world.World.GameOver || !world.World.CamMoving {
if !world.World.GameStarted || world.World.GameOver || !world.World.CamMoving {
return nil
}

100
system/rendermessage.go

@ -0,0 +1,100 @@
package system
import (
"image/color"
"strings"
"code.rocketnine.space/tslocum/brownboxbatman/component"
"code.rocketnine.space/tslocum/brownboxbatman/world"
"code.rocketnine.space/tslocum/gohan"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
type RenderMessageSystem struct {
op *ebiten.DrawImageOptions
logoImg *ebiten.Image
msgImg *ebiten.Image
tmpImg *ebiten.Image
}
func NewRenderMessageSystem() *RenderMessageSystem {
s := &RenderMessageSystem{
op: &ebiten.DrawImageOptions{},
logoImg: ebiten.NewImage(1, 1),
msgImg: ebiten.NewImage(1, 1),
tmpImg: ebiten.NewImage(200, 200),
}
return s
}
func (s *RenderMessageSystem) Needs() []gohan.ComponentID {
return []gohan.ComponentID{
component.PositionComponentID,
component.VelocityComponentID,
component.WeaponComponentID,
}
}
func (s *RenderMessageSystem) Uses() []gohan.ComponentID {
return nil
}
func (s *RenderMessageSystem) Update(_ *gohan.Context) error {
if !world.World.GameStarted || world.World.GameOver || !world.World.MessageVisible {
return nil
}
world.World.MessageTicks++
if world.World.MessageTicks == world.World.MessageDuration {
world.World.MessageVisible = false
return nil
}
return nil
}
func (s *RenderMessageSystem) Draw(_ *gohan.Context, screen *ebiten.Image) error {
if !world.World.GameStarted || !world.World.MessageVisible {
return nil
}
// Draw message.
if world.World.MessageUpdated {
s.drawMessage()
}
bounds := s.msgImg.Bounds()
x := (float64(world.World.ScreenW) / 2) - (float64(bounds.Dx()) / 2)
y := (float64(world.World.ScreenH) / 2) - (float64(bounds.Dy()) / 2)
s.op.GeoM.Reset()
s.op.GeoM.Translate(x, y)
screen.DrawImage(s.msgImg, s.op)
return nil
}
func (s *RenderMessageSystem) drawMessage() {
split := strings.Split(world.World.MessageText, "\n")
width := 0
for _, line := range split {
lineSize := len(line) * 12
if lineSize > width {
width = lineSize
}
}
height := len(split) * 32
const padding = 8
width, height = width+padding*2, height+padding*2
s.msgImg = ebiten.NewImage(width, height)
s.msgImg.Fill(color.RGBA{17, 17, 17, 255})
s.tmpImg.Clear()
s.tmpImg = ebiten.NewImage(width*2, height*2)
s.op.GeoM.Reset()
s.op.GeoM.Scale(2, 2)
s.op.GeoM.Translate(float64(padding), float64(padding))
ebitenutil.DebugPrint(s.tmpImg, world.World.MessageText)
s.msgImg.DrawImage(s.tmpImg, s.op)
s.op.ColorM.Reset()
}

65
world/world.go

@ -3,6 +3,7 @@ package world
import (
"image"
"log"
"math"
"math/rand"
"path/filepath"
@ -32,6 +33,7 @@ var World = &GameWorld{
PlayerWidth: 8,
PlayerHeight: 32,
TileImages: make(map[uint32]*ebiten.Image),
ResetGame: true,
}
type GameWorld struct {
@ -50,7 +52,11 @@ type GameWorld struct {
GameStartedTicks int
GameOver bool
MessageVisible bool
MessageVisible bool
MessageTicks int
MessageDuration int
MessageUpdated bool
MessageText string
PlayerX, PlayerY float64
@ -75,10 +81,10 @@ type GameWorld struct {
BrokenPieceA, BrokenPieceB gohan.Entity
TileImages map[uint32]*ebiten.Image
}
func SetMessage(message string) {
// TODO
ResetGame bool
resetTipShown bool
}
func TileToGameCoords(x, y int) (float64, float64) {
@ -86,6 +92,23 @@ func TileToGameCoords(x, y int) (float64, float64) {
return float64(x) * 32, float64(y) * 32
}
func Reset() {
for _, e := range ECS.Entities() {
ECS.RemoveEntity(e)
}
World.Player = 0
World.ObjectGroups = nil
World.HazardRects = nil
World.CreepRects = nil
World.CreepEntities = nil
World.TriggerEntities = nil
World.TriggerRects = nil
World.TriggerNames = nil
World.MessageVisible = false
}
func LoadMap(filePath string) {
loader := tiled.Loader{
FileSystem: asset.FS,
@ -228,15 +251,19 @@ func (w *GameWorld) SetGameOver(vx, vy float64) {
w.GameOver = true
if rand.Intn(100) == 7 {
asset.SoundBatHit4.Rewind()
asset.SoundBatHit4.Play()
} else {
deathSound := rand.Intn(3)
switch deathSound {
case 0:
asset.SoundBatHit1.Rewind()
asset.SoundBatHit1.Play()
case 1:
asset.SoundBatHit2.Rewind()
asset.SoundBatHit2.Play()
case 2:
asset.SoundBatHit3.Rewind()
asset.SoundBatHit3.Play()
}
}
@ -275,12 +302,25 @@ func (w *GameWorld) SetGameOver(vx, vy float64) {
Image: asset.ImgBatBroken1,
}
ECS.AddComponent(w.BrokenPieceA, pieceASprite)
ECS.AddComponent(w.BrokenPieceA, &component.CreepBulletComponent{
Invulnerable: true,
})
w.BrokenPieceB = entity.NewCreepBullet(position.X, position.Y, xSpeedB, ySpeedB)
pieceBSprite := &component.SpriteComponent{
Image: asset.ImgBatBroken2,
}
ECS.AddComponent(w.BrokenPieceB, pieceBSprite)
ECS.AddComponent(w.BrokenPieceB, &component.CreepBulletComponent{
Invulnerable: true,
})
if !World.resetTipShown {
SetMessage(" GAME OVER\n\nRESET: <ENTER>", math.MaxInt)
World.resetTipShown = true
} else {
SetMessage("GAME OVER", math.MaxInt)
}
}
// TODO move
@ -322,3 +362,20 @@ func NewCreep(creepType int, creepID int64, x float64, y float64) gohan.Entity {
return creep
}
func StartGame() {
if World.GameStarted {
return
}
World.GameStarted = true
asset.SoundLevelMusic.Play()
}
func SetMessage(message string, duration int) {
World.MessageText = message
World.MessageVisible = true
World.MessageUpdated = true
World.MessageDuration = duration
World.MessageTicks = 0
}

Loading…
Cancel
Save