From 4934110fc4b4091c8d9a67c1151c06cfab19f082 Mon Sep 17 00:00:00 2001 From: mel Date: Tue, 18 Aug 2026 01:40:51 +0200 Subject: [PATCH] feat: hide world seed from clients - Generate game seeds server-side when games start - Keep the world hidden until the authoritative start packet arrives --- src/net/local_server.go | 3 +- src/net/packets/game_lifecycle.go | 1 - src/net/packets/packets.go | 2 +- src/server/client.go | 2 +- src/server/lobby_manager.go | 21 ++++-- src/ui/game_world_element.go | 18 +++-- src/ui/screens/game_creation_screen.go | 93 ++++--------------------- src/ui/screens/game_screen.go | 94 ++++++++------------------ 8 files changed, 78 insertions(+), 156 deletions(-) diff --git a/src/net/local_server.go b/src/net/local_server.go index 9229a90..013bee1 100644 --- a/src/net/local_server.go +++ b/src/net/local_server.go @@ -20,7 +20,7 @@ var localGame = struct { // StartLocalGame starts a private one-player server in this process. The // multiplayer socket is suspended until StopLocalGame is called. -func StartLocalGame(seed int64) error { +func StartLocalGame() error { if game.PlayerData == nil { return fmt.Errorf("start local game: player data is not loaded") } @@ -46,7 +46,6 @@ func StartLocalGame(seed int64) error { if err := transport.send(&packets.C2SCreateGamePacket{ Public: false, MaxPlayers: 1, - Seed: seed, }); err != nil { stopLocalGame(true) return fmt.Errorf("create local game: %w", err) diff --git a/src/net/packets/game_lifecycle.go b/src/net/packets/game_lifecycle.go index 585c639..8d78396 100644 --- a/src/net/packets/game_lifecycle.go +++ b/src/net/packets/game_lifecycle.go @@ -5,7 +5,6 @@ import "github.com/threeidiotsonegamejam/gmtk26/src/game" type C2SCreateGamePacket struct { Public bool `json:"public"` MaxPlayers uint8 `json:"max_players"` - Seed int64 `json:"seed"` } type C2SJoinGamePacket struct { diff --git a/src/net/packets/packets.go b/src/net/packets/packets.go index 6bc187f..9ab9372 100644 --- a/src/net/packets/packets.go +++ b/src/net/packets/packets.go @@ -11,7 +11,7 @@ import ( type PacketType int -const protocolVersion uint16 = 1 +const protocolVersion uint16 = 2 const ( UnknownPacketType PacketType = iota diff --git a/src/server/client.go b/src/server/client.go index 4de6363..9067c53 100644 --- a/src/server/client.go +++ b/src/server/client.go @@ -159,7 +159,7 @@ func (c *Client) handleCreateGamePacket(packet *packets.C2SCreateGamePacket) (pa return nil, fatalPacketErrorf("handle create game packet: client is not ready") } - state, err := Lobbies.CreateGame(c, packet.Public, packet.MaxPlayers, packet.Seed) + state, err := Lobbies.CreateGame(c, packet.Public, packet.MaxPlayers) if err != nil { return &packets.S2CGameRejectedPacket{ Operation: "create", diff --git a/src/server/lobby_manager.go b/src/server/lobby_manager.go index cecfaca..4e3b7e7 100644 --- a/src/server/lobby_manager.go +++ b/src/server/lobby_manager.go @@ -2,6 +2,7 @@ package server import ( "crypto/rand" + "encoding/binary" "errors" "fmt" "log" @@ -66,7 +67,7 @@ func NewLobbyManager() *LobbyManager { } } -func (m *LobbyManager) CreateGame(client *Client, public bool, maxPlayers uint8, seed int64) (game.Game, error) { +func (m *LobbyManager) CreateGame(client *Client, public bool, maxPlayers uint8) (game.Game, error) { if maxPlayers < 1 || maxPlayers > 4 { return game.Game{}, fmt.Errorf("max players must be 1, 2, 3, or 4") } @@ -104,9 +105,6 @@ func (m *LobbyManager) CreateGame(client *Client, public bool, maxPlayers uint8, Multiplayer: maxPlayers > 1, MaxPlayers: maxPlayers, Round: 1, - Map: game.Map{ - Seed: seed, - }, } state.Factions[0].Player = playerPointer(player) for i := int(maxPlayers); i < len(state.Factions); i++ { @@ -215,6 +213,13 @@ func (m *LobbyManager) StartGame(client *Client) error { return ErrNotEnoughPlayers } + seed, err := newGameSeed() + if err != nil { + m.mu.Unlock() + return err + } + active.state.Map.Seed = seed + delete(m.lobbies, gameID) delete(m.gameCodes, active.state.GameCode) for _, c := range active.clients { @@ -459,6 +464,14 @@ func (m *LobbyManager) newGameCodeLocked() (string, error) { return "", fmt.Errorf("generate game code: no unique code available") } +func newGameSeed() (int64, error) { + var bytes [8]byte + if _, err := rand.Read(bytes[:]); err != nil { + return 0, fmt.Errorf("generate game seed: %w", err) + } + return int64(binary.LittleEndian.Uint64(bytes[:])), nil +} + func availableFaction(state game.Game) int { for i := 0; i < int(state.MaxPlayers); i++ { if !state.Factions[i].AI && state.Factions[i].Player == nil { diff --git a/src/ui/game_world_element.go b/src/ui/game_world_element.go index 0452779..85dcb2e 100644 --- a/src/ui/game_world_element.go +++ b/src/ui/game_world_element.go @@ -17,15 +17,17 @@ func GameWorld() *GameWorldElement { type GameWorldElement struct { BaseElement[*GameWorldElement] - Map game.Map - Renderer render.WorldRenderer + Map game.Map + Renderer render.WorldRenderer + pendingFocus *game.Hex } func (el *GameWorldElement) prepare() { - if el.Map.Grid == nil { - el.Map.Generate() - } el.Renderer.Init(&el.Map) + if el.pendingFocus != nil { + el.Renderer.FocusOnHex(*el.pendingFocus) + el.pendingFocus = nil + } } func (el *GameWorldElement) update(deltaNano int64) { @@ -36,3 +38,9 @@ func (el *GameWorldElement) update(deltaNano int64) { func (el *GameWorldElement) draw() { el.Renderer.Draw(&el.Map) } + +// FocusOnHex defers camera focus until prepare has initialized the renderer. +// This matters when the world stays hidden throughout the lobby. +func (el *GameWorldElement) FocusOnHex(hex game.Hex) { + el.pendingFocus = &hex +} diff --git a/src/ui/screens/game_creation_screen.go b/src/ui/screens/game_creation_screen.go index 88c0bfe..2325221 100644 --- a/src/ui/screens/game_creation_screen.go +++ b/src/ui/screens/game_creation_screen.go @@ -2,8 +2,6 @@ package screens import ( "fmt" - "hash/fnv" - "math/rand" "strconv" gameNet "github.com/threeidiotsonegamejam/gmtk26/src/net" @@ -28,7 +26,6 @@ var ( creationPublic = true creationSubmitting bool creationError string - creationSeedInput *ui.InputElement creationSubmit *ui.ButtonElement creationVisibility *ui.ButtonElement creationPlayerCount [3]*ui.ButtonElement @@ -52,11 +49,11 @@ func RejectGameCreation(message string) { } func StartSoloWithDefaults() error { - return startSoloGame(rand.Int63()) + return startSoloGame() } func HostGameWithDefaults() error { - return sendHostGame(true, 4, rand.Int63()) + return sendHostGame(true, 4) } func openGameCreation(mode gameCreationMode, previousScreen *ui.ScreenElement) { @@ -66,7 +63,6 @@ func openGameCreation(mode gameCreationMode, previousScreen *ui.ScreenElement) { creationSubmitting = false creationError = "" screen := NewGameCreationScreen(previousScreen) - creationSeedInput.Text = strconv.FormatInt(rand.Int63(), 10) SetActiveScreen(screen) } @@ -111,37 +107,6 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { } }) - creationSeedInput = ui.Input(). - WithPlaceholderText("Seed or phrase"). - WithMaxTextLength(48). - WithDefaultText(""). - WithTextSize(30). - WithPadding(10). - WithSize(vec.Vec2i{X: 360, Y: 54}). - WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: -82, Y: -72}). - WithEnabledDynamic(func(el *ui.InputElement) bool { - return !creationSubmitting - }). - WithCallback(func(text string) { - creationError = "" - }) - - randomSeedButton := ui.Button(). - WithText("Random"). - WithTextSize(28). - WithPadding(8). - WithSize(vec.Vec2i{X: 150, Y: 54}). - WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: 205, Y: -72}). - WithEnabledDynamic(func(el *ui.ButtonElement) bool { - return !creationSubmitting - }). - WithClick(func() { - creationSeedInput.Text = strconv.FormatInt(rand.Int63(), 10) - creationError = "" - }) - for i := range creationPlayerCount { count := uint8(i + 2) creationPlayerCount[i] = ui.Button(). @@ -150,7 +115,7 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { WithPadding(8). WithSize(vec.Vec2i{X: 100, Y: 52}). WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: int32(i-1) * 120, Y: 34}). + WithRelativePos(vec.Vec2i{X: int32(i-1) * 120, Y: -22}). WithVisibleDynamic(func(el *ui.ButtonElement) bool { return hostMode() }). @@ -169,7 +134,7 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { WithPadding(8). WithSize(vec.Vec2i{X: 360, Y: 52}). WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: 0, Y: 104}). + WithRelativePos(vec.Vec2i{X: 0, Y: 48}). WithVisibleDynamic(func(el *ui.ButtonElement) bool { return hostMode() }). @@ -190,9 +155,9 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { WithAnchors(anchor.Center, anchor.Center). WithRelativePosDynamic(func(el *ui.ButtonElement) vec.Vec2i { if hostMode() { - return vec.Vec2i{X: 0, Y: 170} + return vec.Vec2i{X: 0, Y: 114} } - return vec.Vec2i{X: 0, Y: 82} + return vec.Vec2i{X: 0, Y: 48} }). WithEnabledDynamic(func(el *ui.ButtonElement) bool { return !creationSubmitting && (!hostMode() || hostConnected()) @@ -222,30 +187,20 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { if hostMode() { return "Choose the rules" } - return "Choose a world seed before starting" + return "The world is generated when the game starts" }). WithTextSize(26). WithTextColor(uiutil.MenuMutedColor). WithAnchors(anchor.Center, anchor.Top). WithRelativePos(vec.Vec2i{X: 0, Y: 122}), ). - AddChild( - ui.Text(). - WithText("World Seed"). - WithTextSize(25). - WithTextColor(uiutil.MenuMutedColor). - WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: 0, Y: -116}), - ). - AddChild(creationSeedInput). - AddChild(randomSeedButton). AddChild( ui.Text(). WithText("Maximum Players"). WithTextSize(25). WithTextColor(uiutil.MenuMutedColor). WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: 0, Y: -12}). + WithRelativePos(vec.Vec2i{X: 0, Y: -68}). WithVisibleDynamic(func(el *ui.TextElement) bool { return hostMode() }), @@ -256,7 +211,7 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { WithTextSize(26). WithTextColor(uiutil.MenuMutedColor). WithAnchors(anchor.Center, anchor.Center). - WithRelativePos(vec.Vec2i{X: 0, Y: 10}). + WithRelativePos(vec.Vec2i{X: 0, Y: -18}). WithVisibleDynamic(func(el *ui.TextElement) bool { return !hostMode() }), @@ -293,18 +248,14 @@ func NewGameCreationScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { }), ). AddChild(ui.Vignette()). - WithBack(goBack). - WithExit(func() { - creationSeedInput.Blur() - }) + WithBack(goBack) } func submitGameCreation() { - seed := gameSeedFromText(creationSeedInput.Text) creationError = "" if creationMode == gameCreationSolo { - if err := startSoloGame(seed); err != nil { + if err := startSoloGame(); err != nil { creationError = err.Error() return } @@ -312,25 +263,24 @@ func submitGameCreation() { return } - if err := sendHostGame(creationPublic, creationMaxPlayers, seed); err != nil { + if err := sendHostGame(creationPublic, creationMaxPlayers); err != nil { creationError = err.Error() return } creationSubmitting = true } -func startSoloGame(seed int64) error { - return gameNet.StartLocalGame(seed) +func startSoloGame() error { + return gameNet.StartLocalGame() } -func sendHostGame(public bool, maxPlayers uint8, seed int64) error { +func sendHostGame(public bool, maxPlayers uint8) error { if settings.Current.Offline || gameNet.State() != gameNet.ConnectionConnected { return fmt.Errorf("Connect to the multiplayer server before creating a game") } if err := gameNet.Send(&packets.C2SCreateGamePacket{ Public: public, MaxPlayers: maxPlayers, - Seed: seed, }); err != nil { return err } @@ -346,16 +296,3 @@ func gameCreationStatus() string { } return "" } - -func gameSeedFromText(text string) int64 { - if text == "" || text == "0" { - return 0 - } - if seed, err := strconv.ParseInt(text, 10, 64); err == nil { - return seed - } - - hash := fnv.New64a() - _, _ = hash.Write([]byte(text)) - return int64(hash.Sum64()) -} diff --git a/src/ui/screens/game_screen.go b/src/ui/screens/game_screen.go index 7e032f8..fb092fe 100644 --- a/src/ui/screens/game_screen.go +++ b/src/ui/screens/game_screen.go @@ -3,8 +3,6 @@ package screens import ( "fmt" "image/color" - "math/rand" - "strconv" "strings" "time" "unicode" @@ -13,17 +11,15 @@ import ( rl "github.com/gen2brain/raylib-go/raylib" "github.com/threeidiotsonegamejam/gmtk26/src/audio" "github.com/threeidiotsonegamejam/gmtk26/src/game" - "github.com/threeidiotsonegamejam/gmtk26/src/global" gameNet "github.com/threeidiotsonegamejam/gmtk26/src/net" "github.com/threeidiotsonegamejam/gmtk26/src/net/packets" "github.com/threeidiotsonegamejam/gmtk26/src/ui" "github.com/threeidiotsonegamejam/gmtk26/src/ui/anchor" + "github.com/threeidiotsonegamejam/gmtk26/src/ui/uiutil" "github.com/threeidiotsonegamejam/gmtk26/src/util/vec" ) -var gameSeedInput = ui.Input() var gameWorld = ui.GameWorld() -var gameRegenerateButton = ui.Button() var currentGame *game.Game var gamePreviousScreen *ui.ScreenElement var gameLeaveTransition bool @@ -35,6 +31,7 @@ var localClientID game.ClientID // serverGameActive is true between S2CGameStartPacket and S2CGameEndPacket // for both remote multiplayer and the in-process solo server. var serverGameActive bool +var serverGameStarted bool var serverRound int32 var serverCoins int32 var serverPoints int32 @@ -79,6 +76,7 @@ func ApplyServerGameStart(p *packets.S2CGameStartPacket) { return } serverGameActive = true + serverGameStarted = true gameOverMessage = "" gameWorld.Renderer.ClearQueuedBuilding() applyServerRound(p.Round, p.Deadline, p.Map, p.Coins, p.Points, p.Resources) @@ -119,7 +117,6 @@ func applyServerRound(round int32, deadline int64, m game.Map, coins, points int currentGame.Round = round currentGame.Map = m gameWorld.Map = m - gameSeedInput.Text = strconv.FormatInt(m.Seed, 10) } func multiplayerStatusText() string { @@ -162,6 +159,7 @@ func multiplayerStatusText() string { func EnterGame(state game.Game) { clearMatchmaking() serverGameActive = false + serverGameStarted = false gameOverMessage = "" gameActionError = "" gameWorld.Renderer.ClearQueuedBuilding() @@ -250,21 +248,12 @@ func LeaveCurrentGame() { } func applyGameState(state game.Game) { - nextMap := state.Map - if currentGame != nil && - currentGame.GameID == state.GameID && - gameWorld.Map.Seed == nextMap.Seed && - nextMap.Grid == nil { - nextMap = gameWorld.Map - } - if nextMap.Grid == nil { - nextMap.Generate() - } - - state.Map = nextMap - gameWorld.Map = nextMap - gameSeedInput.Text = strconv.FormatInt(nextMap.Seed, 10) currentGame = &state + if !serverGameStarted { + // Lobby updates intentionally contain no world data. Keep the renderer + // empty until the authoritative start packet arrives. + gameWorld.Map = game.Map{} + } } func clearCurrentGame() { @@ -273,6 +262,7 @@ func clearCurrentGame() { gameWorld.Renderer.ClearQueuedBuilding() gameLeaveTransition = false serverGameActive = false + serverGameStarted = false serverGameEndTime = 0 gameOverMessage = "" gameActionError = "" @@ -291,7 +281,7 @@ func focusOnTownhall() { for y := range gameWorld.Map.Grid[x] { cell := &gameWorld.Map.Grid[x][y] if cell.Owner == int8(gameNet.LocalGameState.FactionIdx) && cell.Building == game.BuildingTownhall { - gameWorld.Renderer.FocusOnHex(game.NewHex(int32(x), int32(y))) + gameWorld.FocusOnHex(game.NewHex(int32(x), int32(y))) return } } @@ -304,9 +294,12 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { escScreen = NewEscScreen(nil) screen := ui.Screen(). + WithBackgroundColor(uiutil.MenuScreenBackground). WithEnter(func() { HideEscScreen() - gameWorld.Renderer.ResetCamera(&gameWorld.Map) + if serverGameStarted { + gameWorld.Renderer.ResetCamera(&gameWorld.Map) + } audio.StartMusic() audio.StartAmbience() @@ -318,8 +311,11 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { audio.StopAmbience() clearCurrentGame() }). + AddChild(uiutil.MenuBackdrop()). AddChild( - gameWorld, + gameWorld.WithVisibleDynamic(func(el *ui.GameWorldElement) bool { + return serverGameStarted + }), ). AddChild( ui.Text(). @@ -330,7 +326,8 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { return "Game code: " + currentGame.GameCode }). WithTextSize(28). - WithTextColor(rl.Black). + WithTextColor(uiutil.MenuHeaderColor). + WithTextShadow(color.RGBA{R: 0, G: 0, B: 0, A: 180}, vec.Vec2i{X: 2, Y: 2}). WithAnchors(anchor.TopRight, anchor.TopRight). WithRelativePos(vec.Vec2i{X: -20, Y: 20}), ). @@ -338,7 +335,8 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { ui.Text(). WithTextDynamic(multiplayerStatusText). WithTextSize(26). - WithTextColor(rl.Black). + WithTextColor(uiutil.MenuHeaderColor). + WithTextShadow(color.RGBA{R: 0, G: 0, B: 0, A: 180}, vec.Vec2i{X: 2, Y: 2}). WithAnchors(anchor.Top, anchor.Top). WithRelativePos(vec.Vec2i{X: 0, Y: 20}). WithVisibleDynamic(func(el *ui.TextElement) bool { @@ -381,6 +379,9 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { ui.Group(). WithAnchors(anchor.BottomLeft, anchor.BottomLeft). WithRelativePos(vec.Vec2i{X: 8, Y: -48}). + WithVisibleDynamic(func(el *ui.GroupElement) bool { + return serverGameActive + }). AddChild( ui.Text(). WithTextSize(24). @@ -428,44 +429,6 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { WithClick(setBuildingClick(game.BuildingForester)), ), ). - AddChild( - ui.Group(). - WithAnchors(anchor.TopLeft, anchor.TopLeft). - WithRelativePos(vec.Vec2i{X: 8, Y: 8}). - WithVisibleDynamic(func(el *ui.GroupElement) bool { - return !serverGameActive && global.DebugEnabled - }). - AddChild( - gameSeedInput. - WithPadding(8). - WithTextSize(24). - WithSize(vec.Vec2i{X: 320, Y: 0}). - WithPlaceholderText("Seed"). - WithDefaultText(""), - ). - AddChild( - gameRegenerateButton. - WithPadding(8). - WithTextSize(24). - WithRelativePos(vec.Vec2i{X: 0, Y: 52}). - WithText("Regenerate"). - WithClick(func() { - gameWorld.Map.Seed = gameSeedFromText(gameSeedInput.Text) - gameWorld.Map.Generate() - }), - ). - AddChild( - ui.Button(). - WithPadding(8). - WithTextSize(24). - WithRelativePos(vec.Vec2i{X: 0, Y: 104}). - WithText("Random"). - WithClick(func() { - gameSeedInput.Text = strconv.FormatInt(rand.Int63(), 10) - gameRegenerateButton.Click() - }), - ), - ). AddChild( ui.Group(). WithAnchors(anchor.Left, anchor.Left). @@ -561,7 +524,10 @@ func NewGameScreen(previousScreen *ui.ScreenElement) *ui.ScreenElement { ). AddChild( ui.GameBuildingDetailsPanel(). - WithWorld(gameWorld), + WithWorld(gameWorld). + WithVisibleDynamic(func(el *ui.GameBuildingDetailsPanelElement) bool { + return serverGameActive + }), ). AddChild( ui.Vignette().WithAlpha(120),