City-building simulation video game
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

868 lines
20 KiB

package world
import (
"errors"
"fmt"
"image"
"log"
"math"
"math/rand"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/audio"
"golang.org/x/text/language"
"golang.org/x/text/message"
"code.rocketnine.space/tslocum/citylimits/asset"
"code.rocketnine.space/tslocum/citylimits/component"
. "code.rocketnine.space/tslocum/citylimits/ecs"
"code.rocketnine.space/tslocum/gohan"
"github.com/hajimehoshi/ebiten/v2"
"github.com/lafriks/go-tiled"
)
const startingYear = 1950
const maxPopulation = 100000
const (
MonthTicks = 144 * 5
YearTicks = MonthTicks * 12
)
const TileSize = 64
var DirtTile = uint32(9*32 + (0))
const startingFunds = 10000
const startingZoom = 1.0
const SidebarWidth = 199
var (
GrassTile = uint32(11*32 + (0))
TreeTileA = uint32(5*32 + (24))
TreeTileB = uint32(5*32 + (25))
)
type HUDButton struct {
Sprite *ebiten.Image
SpriteOffsetX, SpriteOffsetY float64
Label string
StructureType int
}
var HUDButtons []*HUDButton
var CameraMinZoom = 0.1
var CameraMaxZoom = 1.0
const startingTax = 0.12
var World = &GameWorld{
CamScale: startingZoom,
CamScaleTarget: startingZoom,
CamMoving: true,
PlayerWidth: 8,
PlayerHeight: 32,
TileImages: make(map[uint32]*ebiten.Image),
ResetGame: true,
Level: NewLevel(256),
Power: newPowerMap(),
PowerOuts: newPowerOuts(),
TaxR: startingTax,
TaxC: startingTax,
TaxI: startingTax,
BuildDragX: -1,
BuildDragY: -1,
LastBuildX: -1,
LastBuildY: -1,
Printer: message.NewPrinter(language.English),
}
type Zone struct {
Type int // StructureResidentialZone, StructureCommercialZone or StructureIndustrialZone
X, Y int
Population int
Powered bool
}
type PowerPlant struct {
Type int
X, Y int
}
type GameWorld struct {
Level *GameLevel
Player gohan.Entity
ScreenW, ScreenH int
DisableEsc bool
Debug int
NoClip bool
GameStarted bool
GameStartedTicks int
GameOver bool
PlayerX, PlayerY float64
CamX, CamY float64
CamScale float64
CamScaleTarget float64
CamMoving bool
PlayerWidth float64
PlayerHeight float64
HoverStructure int
HoverX, HoverY int
HoverLastX, HoverLastY int
HoverValid bool
Map *tiled.Map
ObjectGroups []*tiled.ObjectGroup
HazardRects []image.Rectangle
CreepRects []image.Rectangle
CreepEntities []gohan.Entity
TriggerEntities []gohan.Entity
TriggerRects []image.Rectangle
TriggerNames []string
NativeResolution bool
BrokenPieceA, BrokenPieceB gohan.Entity
TileImages map[uint32]*ebiten.Image
TileImagesFirstGID uint32
ResetGame bool
MuteMusic bool
MuteSoundEffects bool // TODO
GotCursorPosition bool
tilesets []*ebiten.Image
EnvironmentSprites int
SelectedStructure *Structure
HUDUpdated bool
HUDButtonRects []image.Rectangle
RCIButtonRect image.Rectangle
RCIWindowRect image.Rectangle
ShowRCIWindow bool
HelpUpdated bool
HelpPage int
HelpButtonRects []image.Rectangle
PowerPlants []*PowerPlant
Zones []*Zone
HavePowerOut bool
PowerOuts [][]bool
Ticks int
Paused bool
Funds int
Printer *message.Printer
TransparentStructures bool
Messages []string
MessagesTicks []int
Power PowerMap
PowerUpdated bool
PowerAvailable int
PowerNeeded int
BuildDragX int
BuildDragY int
LastBuildX int
LastBuildY int
TaxR float64
TaxC float64
TaxI float64
resetTipShown bool
}
var ErrNothingToBulldoze = errors.New("nothing to bulldoze")
func Reset() {
for _, e := range ECS.Entities() {
ECS.RemoveEntity(e)
}
World.Player = 0
rand.Seed(time.Now().UnixNano())
World.Funds = startingFunds
World.ObjectGroups = nil
World.HazardRects = nil
World.CreepRects = nil
World.CreepEntities = nil
World.TriggerEntities = nil
World.TriggerRects = nil
World.TriggerNames = nil
World.CamX = float64((32 * TileSize) - rand.Intn(64*TileSize))
World.CamY = float64((32 * TileSize) + rand.Intn(32*TileSize))
}
func LoadMap(structureType int) (*tiled.Map, error) {
filePath := StructureFilePaths[structureType]
if filePath == "" {
panic(fmt.Sprintf("unknown structure %d", structureType))
}
// Parse .tmx file.
m, err := tiled.LoadFile(filepath.FromSlash(filePath), tiled.WithFileSystem(asset.FS))
if err != nil {
log.Fatalf("error parsing world: %+v", err)
}
return m, err
}
func DrawMap(structureType int) *ebiten.Image {
img := ebiten.NewImage(128, 128)
m, err := LoadMap(structureType)
if err != nil {
panic(err)
}
var t *tiled.LayerTile
for i, layer := range m.Layers {
for y := 0; y < m.Height; y++ {
for x := 0; x < m.Width; x++ {
t = layer.Tiles[y*m.Width+x]
if t == nil || t.Nil {
continue // No tile at this position.
}
tileImg := World.TileImages[t.Tileset.FirstGID+t.ID]
if tileImg == nil {
continue
}
xi, yi := CartesianToIso(float64(x), float64(y))
scale := 0.9 / float64(m.Width)
if m.Width < 2 {
scale = 0.6
}
paddingX := 64.0
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(xi+(paddingX*(float64(m.Width)-1)), (yi+float64(i*-40))+92)
op.GeoM.Scale(scale, scale)
img.DrawImage(tileImg, op)
}
}
}
return img
}
func LoadTileset() error {
m, err := LoadMap(StructureResidentialLow)
if err != nil {
return err
}
// Load tileset.
if len(World.tilesets) != 0 {
return nil // Already loaded.
}
tileset := m.Tilesets[0]
imgPath := filepath.Join("./image/tileset/", tileset.Image.Source)
f, err := asset.FS.Open(filepath.FromSlash(imgPath))
if err != nil {
panic(err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
panic(err)
}
World.tilesets = append(World.tilesets, ebiten.NewImageFromImage(img))
// Load tiles.
for i := uint32(0); i < uint32(tileset.TileCount); i++ {
rect := tileset.GetTileRect(i)
World.TileImages[i+tileset.FirstGID] = World.tilesets[0].SubImage(rect).(*ebiten.Image)
}
World.TileImagesFirstGID = tileset.FirstGID
return nil
}
func ShowBuildCost(structureType int, cost int) {
if structureType == StructureBulldozer {
ShowMessage(World.Printer.Sprintf("Bulldozed area (-$%d)", cost), 3)
} else if structureType == StructureResidentialZone {
ShowMessage(World.Printer.Sprintf("Zoned area for residential use (-$%d)", cost), 3)
} else if structureType == StructureCommercialZone {
ShowMessage(World.Printer.Sprintf("Zoned area for commercial use (-$%d)", cost), 3)
} else if structureType == StructureIndustrialZone {
ShowMessage(World.Printer.Sprintf("Zoned area for industrial use (-$%d)", cost), 3)
} else {
ShowMessage(World.Printer.Sprintf("Built %s (-$%d)", strings.ToLower(StructureTooltips[World.HoverStructure]), cost), 3)
}
}
func BuildStructure(structureType int, hover bool, placeX int, placeY int) (*Structure, error) {
m, err := LoadMap(structureType)
if err != nil {
return nil, err
}
if m.Width != 1 || m.Height != 1 {
if placeX == 0 {
placeX = 1
}
if placeY == 0 {
placeY = 1
}
}
w := m.Width - 1
h := m.Height - 1
if placeX-w < 0 || placeY-h < 0 || placeX >= 256 || placeY >= 256 {
return nil, errors.New("invalid location: building does not fit")
}
structure := &Structure{
Type: structureType,
X: placeX,
Y: placeY,
}
if structureType == StructureBulldozer && !hover {
// TODO bulldoze entire structure, remove from zones
var bulldozed bool
for i := range World.Level.Tiles {
if World.Level.Tiles[i][placeX][placeY].Sprite != nil {
World.Level.Tiles[i][placeX][placeY].Sprite = nil
bulldozed = true
}
var img *ebiten.Image
if i == 0 {
img = World.TileImages[DirtTile+World.TileImagesFirstGID]
}
if World.Level.Tiles[i][placeX][placeY].EnvironmentSprite != img {
bulldozeTree := World.Level.Tiles[i][placeX][placeY].EnvironmentSprite == World.TileImages[TreeTileA+World.TileImagesFirstGID] || World.Level.Tiles[i][placeX][placeY].EnvironmentSprite == World.TileImages[TreeTileB+World.TileImagesFirstGID]
if bulldozeTree {
sounds := []*audio.Player{
asset.SoundPop1,
asset.SoundPop4,
asset.SoundPop5,
}
sound := sounds[rand.Intn(len(sounds))]
sound.Rewind()
sound.Play()
}
World.Level.Tiles[i][placeX][placeY].EnvironmentSprite = img
bulldozed = true
}
}
if !bulldozed {
return nil, ErrNothingToBulldoze
}
World.Power.SetTile(placeX, placeY, false)
return structure, nil
}
createTileEntity := func(t *tiled.LayerTile, x float64, y float64) gohan.Entity {
mapTile := ECS.NewEntity()
ECS.AddComponent(mapTile, &component.PositionComponent{
X: x,
Y: y,
})
sprite := &component.SpriteComponent{
Image: World.TileImages[t.Tileset.FirstGID+t.ID],
HorizontalFlip: t.HorizontalFlip,
VerticalFlip: t.VerticalFlip,
DiagonalFlip: t.DiagonalFlip,
}
ECS.AddComponent(mapTile, sprite)
return mapTile
}
_ = createTileEntity
// TODO Add entity
tileOccupied := func(tx int, ty int) bool {
return World.Level.Tiles[1][tx][ty].Sprite != nil || (World.Level.Tiles[0][tx][ty].Sprite != nil && (structureType != StructureRoad || World.Level.Tiles[0][tx][ty].Sprite != World.TileImages[World.TileImagesFirstGID]))
}
valid := true
var existingRoadTiles int
VALIDBUILD:
for y := 0; y < m.Height; y++ {
for x := 0; x < m.Width; x++ {
tx, ty := (x+placeX)-w, (y+placeY)-h
if structureType == StructureRoad && World.Level.Tiles[0][tx][ty].Sprite == World.TileImages[World.TileImagesFirstGID] {
existingRoadTiles++
}
if tileOccupied(tx, ty) && structureType != StructureBulldozer {
valid = false
break VALIDBUILD
}
}
}
if structureType == StructureRoad && existingRoadTiles == 4 {
valid = false
}
if hover {
if structureType == StructureBulldozer {
World.HoverValid = true
} else {
World.HoverValid = valid
}
} else if !valid {
return nil, errors.New("invalid location: space already occupied")
}
for y := 0; y < m.Height; y++ {
for x := 0; x < m.Width; x++ {
tx, ty := (x+placeX)-w, (y+placeY)-h
if hover {
if !tileOccupied(tx, ty) || structureType == StructureBulldozer {
if structureType != StructureBulldozer {
World.Level.Tiles[0][tx][ty].HoverSprite = World.TileImages[World.TileImagesFirstGID]
}
// Hide environment sprites temporarily.
for i := 1; i < len(World.Level.Tiles); i++ {
World.Level.Tiles[i][tx][ty].HoverSprite = asset.ImgBlank
}
}
} else {
World.Level.Tiles[0][tx][ty].Sprite = World.TileImages[World.TileImagesFirstGID]
World.Level.Tiles[0][tx][ty].EnvironmentSprite = nil
World.Level.Tiles[1][tx][ty].EnvironmentSprite = nil
}
}
}
var t *tiled.LayerTile
for i, layer := range m.Layers {
for y := 0; y < m.Height; y++ {
for x := 0; x < m.Width; x++ {
t = layer.Tiles[y*m.Width+x]
if t == nil || t.Nil {
continue // No tile at this position.
}
tileImg := World.TileImages[t.Tileset.FirstGID+t.ID]
if tileImg == nil {
continue
}
layerNum := i
if structureType != StructureRoad {
layerNum++
}
for layerNum > len(World.Level.Tiles)-1 {
World.Level.AddLayer()
}
tx, ty := (x+placeX)-w, (y+placeY)-h
if hover {
if !tileOccupied(tx, ty) || structureType == StructureBulldozer {
World.Level.Tiles[layerNum][tx][ty].HoverSprite = World.TileImages[t.Tileset.FirstGID+t.ID]
}
} else {
World.Level.Tiles[layerNum][tx][ty].Sprite = World.TileImages[t.Tileset.FirstGID+t.ID]
if structureType == StructureRoad {
World.Power.SetTile(tx, ty, true)
}
isZone := structureType == StructureResidentialZone || structureType == StructureCommercialZone || structureType == StructureIndustrialZone
if isZone || structureType == StructurePowerPlantCoal || structureType == StructureBulldozer {
World.PowerUpdated = true
}
}
// TODO handle flipping
}
}
}
return structure, nil
}
func ObjectToRect(o *tiled.Object) image.Rectangle {
x, y, w, h := int(o.X), int(o.Y), int(o.Width), int(o.Height)
y -= 32
return image.Rect(x, y, x+w, y+h)
}
func LevelCoordinatesToScreen(x, y float64) (float64, float64) {
return (x - World.CamX) * World.CamScale, (y - World.CamY) * World.CamScale
}
func (w *GameWorld) SetGameOver(vx, vy float64) {
if w.GameOver {
return
}
w.GameOver = true
}
func StartGame() {
if World.GameStarted {
return
}
World.GameStarted = true
if !World.MuteMusic {
asset.SoundMusic.Play()
}
// Show initial help page.
SetHelpPage(0)
}
// CartesianToIso transforms cartesian coordinates into isometric coordinates.
func CartesianToIso(x, y float64) (float64, float64) {
ix := (x - y) * float64(TileSize/2)
iy := (x + y) * float64(TileSize/4)
return ix, iy
}
// CartesianToIso transforms cartesian coordinates into isometric coordinates.
func IsoToCartesian(x, y float64) (float64, float64) {
cx := (x/float64(TileSize/2) + y/float64(TileSize/4)) / 2
cy := (y/float64(TileSize/4) - (x / float64(TileSize/2))) / 2
cx-- // TODO Why is this necessary?
return cx, cy
}
func IsoToScreen(x, y float64) (float64, float64) {
cx, cy := float64(World.ScreenW/2), float64(World.ScreenH/2)
return ((x - World.CamX) * World.CamScale) + cx, ((y - World.CamY) * World.CamScale) + cy
}
func ScreenToIso(x, y int) (float64, float64) {
// Offset cursor to first above ground layer.
y += int(float64(16) * World.CamScale)
cx, cy := float64(World.ScreenW/2), float64(World.ScreenH/2)
return ((float64(x) - cx) / World.CamScale) + World.CamX, ((float64(y) - cy) / World.CamScale) + World.CamY
}
func ScreenToCartesian(x, y int) (float64, float64) {
xi, yi := ScreenToIso(x, y)
return IsoToCartesian(xi, yi)
}
func HUDButtonAt(x, y int) *HUDButton {
point := image.Point{x, y}
for i, rect := range World.HUDButtonRects {
if point.In(rect) {
return HUDButtons[i]
}
}
return nil
}
func HelpButtonAt(x, y int) int {
point := image.Point{x, y}
for i, rect := range World.HelpButtonRects {
if point.In(rect) {
return i
}
}
return -1
}
func AltButtonAt(x, y int) int {
point := image.Point{x, y}
if point.In(World.RCIButtonRect) {
return 0
}
return -1
}
func HandleRCIWindowClick(x, y int) {
if !World.ShowRCIWindow {
return
}
if !ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
return
}
point := image.Point{x, y}
if !point.In(World.RCIWindowRect) {
return
}
var updated bool
barRectR := image.Rect(World.RCIWindowRect.Min.X+381, World.RCIWindowRect.Min.Y, World.RCIWindowRect.Min.X+575, World.RCIWindowRect.Min.Y+50)
barRectC := image.Rect(World.RCIWindowRect.Min.X+381, World.RCIWindowRect.Min.Y+50, World.RCIWindowRect.Min.X+575, World.RCIWindowRect.Min.Y+100)
barRectI := image.Rect(World.RCIWindowRect.Min.X+381, World.RCIWindowRect.Min.Y+100, World.RCIWindowRect.Min.X+575, World.RCIWindowRect.Max.Y)
if point.In(barRectR) {
World.TaxR = float64(x-barRectR.Min.X) / float64(barRectR.Dx())
if World.TaxR >= .99 {
World.TaxR = 1.0
}
World.HUDUpdated = true
updated = true
} else if point.In(barRectC) {
World.TaxC = float64(x-barRectC.Min.X) / float64(barRectC.Dx())
if World.TaxC >= .99 {
World.TaxC = 1.0
}
World.HUDUpdated = true
updated = true
} else if point.In(barRectI) {
World.TaxI = float64(x-barRectI.Min.X) / float64(barRectI.Dx())
if World.TaxI >= .99 {
World.TaxI = 1.0
}
World.HUDUpdated = true
updated = true
}
if !updated {
return
}
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) || World.Ticks%16 == 0 {
sounds := []*audio.Player{
asset.SoundPop1,
asset.SoundPop2,
asset.SoundPop3,
asset.SoundPop4,
asset.SoundPop5,
}
sound := sounds[rand.Intn(len(sounds))]
sound.Rewind()
sound.Play()
}
}
func SetHoverStructure(structureType int) {
World.HoverStructure = structureType
World.HUDUpdated = true
}
func Satisfaction() (r, c, i float64) {
popR, _, _ := Population()
c = float64(popR) / (maxPopulation / 2)
if c > 0.02 {
c = 0.02
}
return 0.02, c, 0.02
}
func TargetPopulation() (r, c, i int) {
currentMax := maxPopulation * ((1 + float64(World.Ticks/(MonthTicks*7))) / 108)
satisfactionR, satisfactionC, satisfactionI := Satisfaction()
return int(satisfactionR * currentMax), int(satisfactionC * currentMax), int(satisfactionI * currentMax)
}
func Demand() (r, c, i float64) {
targetR, targetC, targetI := TargetPopulation()
populationR, populationC, populationI := Population()
r, c, i = float64(targetR)-float64(populationR), float64(targetC)-float64(populationC), float64(targetI)-float64(populationI)
max := r
if c > max {
max = c
}
if i > max {
max = i
}
barPeak := 100.0
r, c, i = r/barPeak, c/barPeak, i/barPeak
r, c, i = r*(1-World.TaxR), c*(1-World.TaxC), i*(1-World.TaxI)
clamp := func(v float64) float64 {
if math.IsNaN(v) {
return 0
}
if v < -1 {
v = -1
} else if v > 1 {
v = 1
}
return v
}
return clamp(r), clamp(c), clamp(i)
}
var StructureTooltips = map[int]string{
StructureToggleHelp: "Help",
StructureToggleTransparentStructures: "Transparent buildings",
StructureBulldozer: "Bulldozer",
StructureRoad: "Road",
StructurePoliceStation: "Police station",
StructurePowerPlantCoal: "Coal power plant",
StructurePowerPlantSolar: "Solar power plant",
StructurePowerPlantNuclear: "Nuclear plant",
StructureResidentialZone: "Residential zone",
StructureCommercialZone: "Commercial zone",
StructureIndustrialZone: "Industrial zone",
}
var StructureCosts = map[int]int{
StructureBulldozer: 5,
StructureRoad: 25,
StructurePoliceStation: 1000,
StructurePowerPlantCoal: 4000,
StructurePowerPlantSolar: 10000,
StructurePowerPlantNuclear: 25000,
StructureResidentialZone: 100,
StructureCommercialZone: 200,
StructureIndustrialZone: 100,
}
func Tooltip() string {
tooltipText := StructureTooltips[World.HoverStructure]
cost := StructureCosts[World.HoverStructure]
if cost > 0 {
tooltipText += World.Printer.Sprintf("\n$%d", cost)
}
return tooltipText
}
var monthNames = []string{
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
}
func Date() (month string, year string) {
y, m := World.Ticks/YearTicks, (World.Ticks%YearTicks)/MonthTicks
return monthNames[m], strconv.Itoa(startingYear + y)
}
func Population() (r, c, i int) {
for _, zone := range World.Zones {
switch zone.Type {
case StructureResidentialZone:
r += zone.Population
case StructureCommercialZone:
c += zone.Population
case StructureIndustrialZone:
i += zone.Population
}
}
return r, c, i
}
var messageLock = &sync.Mutex{}
const messageDuration = 144 * 3
func TickMessages() {
messageLock.Lock()
defer messageLock.Unlock()
var removed int
for j := 0; j < len(World.MessagesTicks); j++ {
i := j - removed
if World.MessagesTicks[i] == 0 {
World.Messages = append(World.Messages[:i], World.Messages[i+1:]...)
World.MessagesTicks = append(World.MessagesTicks[:i], World.MessagesTicks[i+1:]...)
removed++
World.HUDUpdated = true
} else if World.MessagesTicks[i] > 0 {
World.MessagesTicks[i]--
}
}
}
func ShowMessage(message string, duration int) {
messageLock.Lock()
defer messageLock.Unlock()
World.Messages = append(World.Messages, message)
World.MessagesTicks = append(World.MessagesTicks, duration)
World.HUDUpdated = true
}
func ValidXY(x, y int) bool {
return x >= 0 && y >= 0 && x < 256 && y < 256
}
var PowerPlantCapacities = map[int]int{
StructurePowerPlantCoal: 60,
StructurePowerPlantSolar: 40,
StructurePowerPlantNuclear: 200,
}
var ZonePowerRequirement = map[int]int{
StructureResidentialZone: 1,
StructureCommercialZone: 1,
StructureIndustrialZone: 1,
}
func SetHelpPage(page int) {
World.HelpPage = page
World.HelpUpdated = true
World.HUDUpdated = true
}
func IsPowerPlant(structureType int) bool {
return structureType == StructurePowerPlantCoal || structureType == StructurePowerPlantSolar || structureType == StructurePowerPlantNuclear
}
func IsZone(structureType int) bool {
return structureType == StructureResidentialZone || structureType == StructureCommercialZone || structureType == StructureIndustrialZone
}