From 56b32db68568c6713332d113227d2a2619ee5ccb Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Wed, 13 May 2026 21:58:09 +0200 Subject: [PATCH 01/13] per-player mouse force Decouple the mouse force from the single global current-player state so each of the two players can independently drive their own marble. This is a prerequisite for both LAN two-player and any future local split-input two-player mode. MouseForce now holds an array of forces (one per player) and exposes both a per-actor sum (used by ordinary floors) and a per-player accessor (used by YinyangFloor, where the floor color selects the input). Floor::process_mouseforce drops the controlled_by gate because the per-actor sum already encodes it. YinyangFloor queries the per-player force directly so its "floor color selects whose input passes" semantics are preserved. server::Msg_MouseForce and world::SetMouseForce gain a player index. The client passes player::CurrentPlayer() so single-player behavior is unchanged: only one slot is non-zero per tick, and the sum collapses to the previous behavior. The netgame stub hard-codes player 0 for now; a real protocol comes in phase 3. Also add an indexed player::InhibitPickup overload so a future LAN client can inhibit pickup for its own marble without touching the active player. --- src/client.cc | 3 ++- src/floors.cc | 8 +++---- src/floors/SimpleFloors.cc | 13 +++++++----- src/netgame.cc | 4 +++- src/player.cc | 5 +++++ src/player.hh | 1 + src/server.cc | 4 ++-- src/server.hh | 2 +- src/world.cc | 8 +++++-- src/world.hh | 3 ++- src/world_internal.hh | 43 +++++++++++++++++++++++++++++++------- 11 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/client.cc b/src/client.cc index 7dea77f52..85fb3e507 100644 --- a/src/client.cc +++ b/src/client.cc @@ -208,7 +208,8 @@ void Client::handle_events() { if (abs(e.motion.xrel) > 300 || abs(e.motion.yrel) > 300) { fprintf(stderr, "mouse event with %i, %i\n", e.motion.xrel, e.motion.yrel); } else - server::Msg_MouseForce(options::GetDouble("MouseSpeed") * + server::Msg_MouseForce(player::CurrentPlayer(), + options::GetDouble("MouseSpeed") * ecl::V2(e.motion.xrel, e.motion.yrel)); break; case SDL_MOUSEBUTTONDOWN: diff --git a/src/floors.cc b/src/floors.cc index c96e8b5cc..1ce1831b3 100644 --- a/src/floors.cc +++ b/src/floors.cc @@ -73,10 +73,10 @@ Value Floor::message(const Message &m) { } ecl::V2 Floor::process_mouseforce(Actor *a, ecl::V2 force) { - if (a->controlled_by(player::CurrentPlayer())) - return getAdhesion() * force; - else - return ecl::V2(); + // The per-actor sum in MouseForce::get_force already zeros the + // force for actors not controlled by any active-input player, + // so we just scale by the floor's adhesion here. + return getAdhesion() * force; } void Floor::setAttr(const string &key, const Value &val) { diff --git a/src/floors/SimpleFloors.cc b/src/floors/SimpleFloors.cc index 342e6b47b..ce9ad15d7 100644 --- a/src/floors/SimpleFloors.cc +++ b/src/floors/SimpleFloors.cc @@ -165,11 +165,14 @@ namespace enigma { objFlags & OBJBIT_INVISIBLE ? "_invisible" : ""); } - ecl::V2 YinyangFloor::process_mouseforce (Actor *a, ecl::V2 force) { - if (player::CurrentPlayer() == state) - return getAdhesion() * force; - else - return ecl::V2(); + ecl::V2 YinyangFloor::process_mouseforce (Actor *a, ecl::V2 /*force*/) { + // A yinyang floor passes through only the input from the player + // matching the floor's color (state 0 = yin/black, 1 = yang/white), + // applied to whichever marble is on the floor — same as the + // original single-player semantics. The `force` argument is the + // per-actor sum and would mask out cross-color cases, so we + // query the per-player force directly instead. + return getAdhesion() * GetMouseForceForPlayer(a, state); } BOOT_REGISTER_START diff --git a/src/netgame.cc b/src/netgame.cc index 4f8df8961..fa66eb7f5 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -77,7 +77,9 @@ void handle_client_packet(ecl::Buffer &b, int player_no) { float dx, dy; if (b >> dx >> dy) { printf("-- yei!\n"); - server::Msg_MouseForce(ecl::V2(dx, dy)); + // TODO(phase-3): protocol should carry the player index + // explicitly per peer. The stub hard-codes player 0. + server::Msg_MouseForce(0, ecl::V2(dx, dy)); } break; } diff --git a/src/player.cc b/src/player.cc index 90bcc226e..66a9ca2f4 100644 --- a/src/player.cc +++ b/src/player.cc @@ -495,6 +495,11 @@ void player::InhibitPickup(bool flag) { players[icurrent_player].inhibit_pickup = flag; } +void player::InhibitPickup(int iplayer, bool flag) { + if ((unsigned)iplayer < players.size()) + players[iplayer].inhibit_pickup = flag; +} + /*! Return pointer to inventory if actor may pick up items, 0 otherwise. */ Inventory *player::MayPickup(Actor *a, Item *it, bool allowFlying) { diff --git a/src/player.hh b/src/player.hh index 509146372..2e32cbee1 100644 --- a/src/player.hh +++ b/src/player.hh @@ -86,6 +86,7 @@ Actor *GetMainActor(unsigned iplayer); bool AllActorsDead(); void InhibitPickup(bool yesno); +void InhibitPickup(int iplayer, bool yesno); void PickupItem(Actor *a, enigma::GridPos p); bool PickupAsItem(Actor *a, GridObject *obj, std::string kind); void RotateInventory(int dir = 1); diff --git a/src/server.cc b/src/server.cc index eaceb9d0c..a9d681e8d 100644 --- a/src/server.cc +++ b/src/server.cc @@ -604,8 +604,8 @@ void Msg_Panic(bool onoff) { state = sv_running; } -void Msg_MouseForce(const ecl::V2 &f) { - SetMouseForce(f); +void Msg_MouseForce(int player, const ecl::V2 &f) { + SetMouseForce(player, f); } void SetCompatibility(const char *version) { diff --git a/src/server.hh b/src/server.hh index 28c34cec4..a38958253 100644 --- a/src/server.hh +++ b/src/server.hh @@ -207,7 +207,7 @@ void Msg_Teatime(bool onoff); void Msg_Panic(bool onoff); -void Msg_MouseForce(const ecl::V2 &f); +void Msg_MouseForce(int player, const ecl::V2 &f); void Msg_ActivateItem(); diff --git a/src/world.cc b/src/world.cc index 1d6c670d9..4fc7067e9 100644 --- a/src/world.cc +++ b/src/world.cc @@ -1743,8 +1743,12 @@ bool WorldInitLevel() { return true; } -void SetMouseForce(V2 f) { - level->m_mouseforce.add_force(f); +void SetMouseForce(int player, V2 f) { + level->m_mouseforce.add_force(player, f); +} + +V2 GetMouseForceForPlayer(Actor *a, int player) { + return level->m_mouseforce.get_force_for_player(a, player); } void NameObject(Object *obj, const std::string &name) { diff --git a/src/world.hh b/src/world.hh index 0d83c3a65..abf4a8b83 100644 --- a/src/world.hh +++ b/src/world.hh @@ -178,7 +178,8 @@ PositionList GetNamedPositionList(const std::string &templ, Object *reference = void AddForceField(ForceField *ff); void RemoveForceField(ForceField *ff); -void SetMouseForce(ecl::V2 f); +void SetMouseForce(int player, ecl::V2 f); +ecl::V2 GetMouseForceForPlayer(Actor *a, int player); void SetGlobalForce(ecl::V2 force); ecl::V2 GetGlobalForce(); diff --git a/src/world_internal.hh b/src/world_internal.hh index 63a0dc29e..62ed9031e 100644 --- a/src/world_internal.hh +++ b/src/world_internal.hh @@ -44,23 +44,50 @@ typedef std::vector SignalList; /*! This class implements the "force field" that accelerates objects when the mouse is moved. Only objects that have the "mouseforce" and the "controllers" attributes set are affected - by this force field. */ + by this force field. + + Each player (0 = black/yin, 1 = white/yang) has their own + independent force vector, so two simultaneous inputs (LAN + multiplayer, future split-input local 2P) can each push their + own marble. In single-player mode only one slot is non-zero + per tick, so the sum collapses to the previous behavior. */ class MouseForce { public: - void set_force(ecl::V2 f) { force = f; } - void add_force(ecl::V2 f) { force += f; } + static constexpr int kNumPlayers = 2; + + void set_force(int player, ecl::V2 f) { forces[player] = f; } + void add_force(int player, ecl::V2 f) { forces[player] += f; } - ecl::V2 get_force(Actor *a) { + /*! Sum of forces from every player that controls this actor, + scaled by the actor's adhesion. Used by ordinary floors. */ + ecl::V2 get_force(Actor *a) const { if (a->is_flying() || a->is_dead()) return ecl::V2(); - else - return force * a->get_mouseforce(); + ecl::V2 result; + for (int p = 0; p < kNumPlayers; ++p) { + if (a->controlled_by(p)) + result += forces[p]; + } + return result * a->get_mouseforce(); } - void tick(double /*dtime*/) { force = ecl::V2(); } + /*! Raw per-player force scaled by the actor's adhesion, + ignoring the actor's controllers mask. Used by YinyangFloor, + where the floor color (not the marble color) selects which + player's input is in effect. */ + ecl::V2 get_force_for_player(Actor *a, int player) const { + if (a->is_flying() || a->is_dead()) + return ecl::V2(); + return forces[player] * a->get_mouseforce(); + } + + void tick(double /*dtime*/) { + for (int p = 0; p < kNumPlayers; ++p) + forces[p] = ecl::V2(); + } private: - ecl::V2 force; + ecl::V2 forces[kNumPlayers]; }; /* -------------------- Scramble -------------------- */ From f916789d858d81a88f7333d12e568c20d610c41f Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Wed, 13 May 2026 22:09:36 +0200 Subject: [PATCH 02/13] add server->client EventSink tap Introduce client::EventSink, an observer interface with hooks for every cross-cutting server->client effect: the existing Msg_* surface (command, advance-level, jump-back, level-loaded, player-position, sparkle, show-text/document, finished-text, teatime, play-sound, error) plus two per-tick engine taps for actor position/velocity and actor sprite model. Each Msg_* notifies any registered sink before performing its local effect, so the host's display path is unchanged. The engine taps are placed at the mutation choke-points: Actor::set_model and World::move_actors (once per actor per tick, after the sub-stepped integration completes). Msg_ShowDocument now updates the status bar directly instead of calling Msg_ShowText so the OnShowText hook does not double-fire. No sinks are registered yet; this is purely the outbound seam. --- src/actors.cc | 2 ++ src/client.cc | 40 +++++++++++++++++++++++++++++++++++++++- src/client.hh | 43 +++++++++++++++++++++++++++++++++++++++++++ src/netgame.cc | 4 ++-- src/world.cc | 6 ++++++ 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/actors.cc b/src/actors.cc index b1194917e..a5e3c08d3 100644 --- a/src/actors.cc +++ b/src/actors.cc @@ -19,6 +19,7 @@ */ #include "errors.hh" +#include "client.hh" #include "enigma.hh" #include "player.hh" #include "Inventory.hh" @@ -312,6 +313,7 @@ void Actor::move_screen() { void Actor::set_model(const std::string &name) { m_sprite.replace_model(display::MakeModel(name)); + client::NotifyActorSpriteChanged(getId(), name); } void Actor::animcb() { diff --git a/src/client.cc b/src/client.cc index 85fb3e507..f2c530e0d 100644 --- a/src/client.cc +++ b/src/client.cc @@ -47,8 +47,10 @@ #include "enet/enet.h" #include "enet_ver.hh" +#include #include #include +#include #include "client_internal.hh" @@ -100,8 +102,29 @@ namespace { Client client_instance; const char HSEP = '^'; // history separator (use character that user cannot use) +std::vector event_sinks; + } // namespace +void RegisterEventSink(EventSink *sink) { + if (sink && std::find(event_sinks.begin(), event_sinks.end(), sink) == event_sinks.end()) + event_sinks.push_back(sink); +} + +void UnregisterEventSink(EventSink *sink) { + auto it = std::find(event_sinks.begin(), event_sinks.end(), sink); + if (it != event_sinks.end()) + event_sinks.erase(it); +} + +void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) { + for (auto *s : event_sinks) s->OnActorMoved(actor_id, pos, vel); +} + +void NotifyActorSpriteChanged(int actor_id, const std::string &model_name) { + for (auto *s : event_sinks) s->OnActorSpriteChanged(actor_id, model_name); +} + /* -------------------- Client class -------------------- */ Client::Client() @@ -960,6 +983,7 @@ bool NetworkStart() { void Msg_LevelLoaded(bool isRestart) { client_instance.level_loaded(isRestart); + for (auto *s : event_sinks) s->OnLevelLoaded(isRestart); } void Tick(double dtime) { @@ -972,6 +996,7 @@ void Stop() { } void Msg_AdvanceLevel(lev::LevelAdvanceMode mode) { + for (auto *s : event_sinks) s->OnAdvanceLevel(mode); lev::Index *level_index = lev::Index::getCurrentIndex(); // log last played level lev::PersistentIndex::addCurrentToHistory(); @@ -984,6 +1009,7 @@ void Msg_AdvanceLevel(lev::LevelAdvanceMode mode) { } void Msg_JumpBack() { + for (auto *s : event_sinks) s->OnJumpBack(); // log last played level lev::PersistentIndex::addCurrentToHistory(); server::Msg_JumpBack(); @@ -994,6 +1020,7 @@ bool AbortGameP() { } void Msg_Command(const std::string &cmd) { + for (auto *s : event_sinks) s->OnCommand(cmd); if (cmd == "abort") { client_instance.abort(); } else if (cmd == "level_finished") { @@ -1009,6 +1036,7 @@ void Msg_Command(const std::string &cmd) { } void Msg_PlayerPosition(unsigned iplayer, const ecl::V2 &pos) { + for (auto *s : event_sinks) s->OnPlayerPosition(iplayer, pos); if (iplayer == (unsigned)player::CurrentPlayer()) { sound::SetListenerPosition(pos); display::SetReferencePoint(pos); @@ -1016,31 +1044,40 @@ void Msg_PlayerPosition(unsigned iplayer, const ecl::V2 &pos) { } void Msg_PlaySound(const std::string &wavfile, const ecl::V2 &pos, double relative_volume) { + for (auto *s : event_sinks) s->OnPlaySound(wavfile, pos, relative_volume); sound::EmitSoundEvent(wavfile.c_str(), pos, relative_volume); } void Msg_PlaySound(const std::string &wavfile, double relative_volume) { + for (auto *s : event_sinks) s->OnPlaySoundRelative(wavfile, relative_volume); sound::EmitSoundEvent(wavfile.c_str(), ecl::V2(), relative_volume); } void Msg_Sparkle(const ecl::V2 &pos) { + for (auto *s : event_sinks) s->OnSparkle(pos); display::AddEffect(pos, "ring-anim", true); } void Msg_ShowText(const std::string &text, bool scrolling, double duration) { + for (auto *s : event_sinks) s->OnShowText(text, scrolling, duration); display::GetStatusBar()->show_text(text, scrolling, duration); } void Msg_ShowDocument(const std::string &text, bool scrolling, double duration) { + for (auto *s : event_sinks) s->OnShowDocument(text, scrolling, duration); client_instance.registerDocument(text); - Msg_ShowText(text, scrolling, duration); + // Don't call Msg_ShowText: that would re-fire OnShowText for the same + // payload. + display::GetStatusBar()->show_text(text, scrolling, duration); } void Msg_FinishedText() { + for (auto *s : event_sinks) s->OnFinishedText(); client_instance.finishedText(); } void Msg_Teatime(bool onoff) { + for (auto *s : event_sinks) s->OnTeatime(onoff); if (onoff) Msg_ShowText(_("Teatime!"), false, 0.1); // Note that client's time does not tick during teatime, @@ -1049,6 +1086,7 @@ void Msg_Teatime(bool onoff) { } void Msg_Error(const std::string &text) { + for (auto *s : event_sinks) s->OnError(text); client_instance.error(text); } diff --git a/src/client.hh b/src/client.hh index e6bd75fc9..1714aa4c5 100644 --- a/src/client.hh +++ b/src/client.hh @@ -18,8 +18,11 @@ #ifndef CLIENT_HH_INCLUDED #define CLIENT_HH_INCLUDED +#include "ecl_math.hh" #include "lev/Index.hh" +#include + namespace enigma { namespace client { @@ -36,6 +39,46 @@ bool AbortGameP(); void Stop(); +/* -------------------- Outbound event sink -------------------- + + Every cross-cutting server→client effect (Msg_* below, plus the + Notify* hooks at engine mutation choke-points) is pushed to any + registered EventSink in addition to taking its normal local effect. + Used as a tap for an outbound network stream; default no-op so + subclasses override only what they care about. +*/ + +class EventSink { +public: + virtual ~EventSink() = default; + + virtual void OnCommand(const std::string &cmd) {} + virtual void OnAdvanceLevel(lev::LevelAdvanceMode mode) {} + virtual void OnJumpBack() {} + virtual void OnLevelLoaded(bool isRestart) {} + virtual void OnPlayerPosition(unsigned iplayer, const ecl::V2 &pos) {} + virtual void OnSparkle(const ecl::V2 &pos) {} + virtual void OnShowText(const std::string &text, bool scrolling, double duration) {} + virtual void OnShowDocument(const std::string &text, bool scrolling, double duration) {} + virtual void OnFinishedText() {} + virtual void OnTeatime(bool onoff) {} + virtual void OnPlaySound(const std::string &soundname, const ecl::V2 &pos, + double relative_volume) {} + virtual void OnPlaySoundRelative(const std::string &soundname, double relative_volume) {} + virtual void OnError(const std::string &text) {} + + virtual void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) {} + virtual void OnActorSpriteChanged(int actor_id, const std::string &model_name) {} +}; + +void RegisterEventSink(EventSink *sink); +void UnregisterEventSink(EventSink *sink); + +// Notify hooks for engine mutation points that don't pass through +// a Msg_* function. No local side effect; purely a tap for sinks. +void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel); +void NotifyActorSpriteChanged(int actor_id, const std::string &model_name); + /* -------------------- Server->Client messages -------------------- */ void Msg_Command(const std::string &cmd); diff --git a/src/netgame.cc b/src/netgame.cc index fa66eb7f5..675349f8a 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -77,8 +77,8 @@ void handle_client_packet(ecl::Buffer &b, int player_no) { float dx, dy; if (b >> dx >> dy) { printf("-- yei!\n"); - // TODO(phase-3): protocol should carry the player index - // explicitly per peer. The stub hard-codes player 0. + // TODO: protocol should carry the player index per peer; + // hard-coded to 0 until the wire format is fleshed out. server::Msg_MouseForce(0, ecl::V2(dx, dy)); } break; diff --git a/src/world.cc b/src/world.cc index 4fc7067e9..aaea729ce 100644 --- a/src/world.cc +++ b/src/world.cc @@ -1432,6 +1432,12 @@ void World::move_actors(double dtime) { rest_time -= dt; } + + for (unsigned i = 0; i < nactors; ++i) { + Actor *a = actorlist[i]; + const ActorInfo &ai = *a->get_actorinfo(); + client::NotifyActorMoved(a->getId(), ai.pos, ai.vel); + } } /* This function performs one step in the numerical integration of an From a7996b80c97c8e8a6ae40d3683feb656a296de59 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Wed, 13 May 2026 22:18:48 +0200 Subject: [PATCH 03/13] add wire-protocol foundation: tags, NetworkSink, dispatchers Define the CL_*/SV_* tag set for the LAN protocol, a NetworkSink that batches outbound EventSink hooks into reliable and unreliable buffers, and the two dispatcher functions that parse inbound packets on each side. NetworkSink subclasses client::EventSink. Per-tick volume events (actor moves, player position) go unreliable on channel 0; the rest go reliable on channel 1. flush() ships both buffers and clears them; called once per tick from the host's server loop. dispatch_input_from_client tags inputs with the connection's player index so the server simulation knows which marble to drive. dispatch_event_from_server applies events on the remote side: actor positions write ActorInfo directly and trigger a sprite move; sprite changes call Actor::set_model. Promote Actor::set_model from protected to public so the dispatcher can apply remote sprite updates; set_anim stays internal (it wires up animation callbacks the engine owns). The host's server_loop now registers a NetworkSink for the duration of the connection and flushes it each tick. The remote's join loop sends CL_MOUSE_FORCE on mouse motion (batched per frame) and feeds inbound packets to dispatch_event_from_server. The actual handshake, level transport, and end-to-end LAN play come next; this commit lays the substrate. --- src/actors.hh | 2 +- src/netgame.cc | 329 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 261 insertions(+), 70 deletions(-) diff --git a/src/actors.hh b/src/actors.hh index bdd37d884..990502b8d 100644 --- a/src/actors.hh +++ b/src/actors.hh @@ -155,6 +155,7 @@ public: virtual void move_screen(); void warp(const ecl::V2 &newpos); bool sound_event(const char *name, double vol = 1.0); + void set_model(const std::string &modelname); void respawn(); void set_respawnpos(const ecl::V2 &p); @@ -196,7 +197,6 @@ protected: virtual Object::ObjectType getObjectType() const override { return Object::ACTOR; } Actor(const ActorTraits &tr); - void set_model(const std::string &modelname); void set_anim(const std::string &modelname); display::SpriteHandle &get_sprite() { return m_sprite; } diff --git a/src/netgame.cc b/src/netgame.cc index 675349f8a..05581ff1b 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -17,11 +17,13 @@ * */ #include "errors.hh" +#include "actors.hh" #include "client.hh" #include "main.hh" #include "netgame.hh" #include "network.hh" #include "options.hh" +#include "player.hh" #include "server.hh" #include "enet/enet.h" @@ -35,65 +37,263 @@ using namespace enigma; #include "client_internal.hh" //====================================================================== -// SERVER +// Wire protocol //====================================================================== -namespace enigma { -namespace server { +namespace { -enum ServerMessageTypes { - SVMSG_NOOP, - SVMSG_LOADLEVEL, - SVMSG_MOUSEFORCE, - SVMSG_ACTIVATEITEM, +// Packet tags. Client -> host inputs live in the 0x10–0x7F band, host +// -> client events in 0x80+. The bands are separate so a malformed +// peer cannot confuse one for the other. +enum ProtoTag : Uint8 { + // Client -> host (inputs) + CL_MOUSE_FORCE = 0x10, + CL_ACTIVATE_ITEM = 0x11, + CL_ROTATE_INVENTORY = 0x12, + CL_COMMAND = 0x13, + CL_INHIBIT_PICKUP = 0x14, + + // Host -> client (outbound EventSink stream) + SV_COMMAND = 0x80, + SV_ADVANCE_LEVEL = 0x81, + SV_JUMP_BACK = 0x82, + SV_LEVEL_LOADED = 0x83, + SV_PLAYER_POSITION = 0x84, + SV_SPARKLE = 0x85, + SV_PLAY_SOUND = 0x86, + SV_PLAY_SOUND_REL = 0x87, + SV_SHOW_TEXT = 0x88, + SV_SHOW_DOCUMENT = 0x89, + SV_FINISHED_TEXT = 0x8A, + SV_TEATIME = 0x8B, + SV_ERROR = 0x8C, + SV_ACTOR_MOVED = 0x8D, + SV_ACTOR_SPRITE = 0x8E, }; -} // namespace server -} // namespace enigma - -void handle_client_packet(ecl::Buffer &b, int player_no) { - Uint8 code; - while (b >> code) { - switch (code) { - case server::SVMSG_NOOP: - break; // no nothing - - // not yet used -- rewrite to index/proxy usage - // case SVMSG_LOADLEVEL: { - // Uint16 levelno; - // string levelpack; - // if (b >> levelno >> levelpack) { - // printf ("SV: Loading level %d from levelpack %s\n", int(levelno), - // levelpack.c_str()); - // server::Msg_SetLevelPack (levelpack); - // server::Msg_LoadLevel (levelno); - // } - // - // break; - // } - - case server::SVMSG_MOUSEFORCE: { - printf("mouse force\n"); +constexpr int CHANNEL_UNRELIABLE = 0; +constexpr int CHANNEL_RELIABLE = 1; + +// Batches outbound EventSink calls into a reliable and an unreliable +// buffer; flush() ships both. Position-style updates that arrive every +// tick go unreliable; everything else is reliable. +class NetworkSink : public client::EventSink { +public: + explicit NetworkSink(Peer *peer) : m_peer(peer) {} + + void OnCommand(const std::string &cmd) override { + m_reliable << Uint8(SV_COMMAND) << cmd; + } + void OnAdvanceLevel(lev::LevelAdvanceMode mode) override { + m_reliable << Uint8(SV_ADVANCE_LEVEL) << Uint8(mode); + } + void OnJumpBack() override { + m_reliable << Uint8(SV_JUMP_BACK); + } + void OnLevelLoaded(bool isRestart) override { + m_reliable << Uint8(SV_LEVEL_LOADED) << Uint8(isRestart ? 1 : 0); + } + void OnPlayerPosition(unsigned iplayer, const ecl::V2 &pos) override { + m_unreliable << Uint8(SV_PLAYER_POSITION) << Uint8(iplayer) + << float(pos[0]) << float(pos[1]); + } + void OnSparkle(const ecl::V2 &pos) override { + m_reliable << Uint8(SV_SPARKLE) << float(pos[0]) << float(pos[1]); + } + void OnShowText(const std::string &text, bool scrolling, double duration) override { + m_reliable << Uint8(SV_SHOW_TEXT) << text + << Uint8(scrolling ? 1 : 0) << double(duration); + } + void OnShowDocument(const std::string &text, bool scrolling, double duration) override { + m_reliable << Uint8(SV_SHOW_DOCUMENT) << text + << Uint8(scrolling ? 1 : 0) << double(duration); + } + void OnFinishedText() override { + m_reliable << Uint8(SV_FINISHED_TEXT); + } + void OnTeatime(bool onoff) override { + m_reliable << Uint8(SV_TEATIME) << Uint8(onoff ? 1 : 0); + } + void OnPlaySound(const std::string &soundname, const ecl::V2 &pos, + double relative_volume) override { + m_reliable << Uint8(SV_PLAY_SOUND) << soundname + << float(pos[0]) << float(pos[1]) << double(relative_volume); + } + void OnPlaySoundRelative(const std::string &soundname, + double relative_volume) override { + m_reliable << Uint8(SV_PLAY_SOUND_REL) << soundname << double(relative_volume); + } + void OnError(const std::string &text) override { + m_reliable << Uint8(SV_ERROR) << text; + } + void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) override { + m_unreliable << Uint8(SV_ACTOR_MOVED) << Uint32(actor_id) + << float(pos[0]) << float(pos[1]) + << float(vel[0]) << float(vel[1]); + } + void OnActorSpriteChanged(int actor_id, const std::string &model_name) override { + m_reliable << Uint8(SV_ACTOR_SPRITE) << Uint32(actor_id) << model_name; + } + + void flush() { + if (m_reliable.size() > 0) { + m_peer->send_reliable(m_reliable, CHANNEL_RELIABLE); + m_reliable.clear(); + } + if (m_unreliable.size() > 0) { + m_peer->send_message(m_unreliable, CHANNEL_UNRELIABLE); + m_unreliable.clear(); + } + } + +private: + Peer *m_peer; + ecl::Buffer m_reliable; + ecl::Buffer m_unreliable; +}; + +// Parse a packet that arrived on the host. `player` is the index +// assigned to that connection (0 or 1); inputs are tagged with it on +// the server side so the simulation knows whose marble to drive. +void dispatch_input_from_client(ecl::Buffer &b, int player) { + Uint8 tag; + while (b >> tag) { + switch (tag) { + case CL_MOUSE_FORCE: { float dx, dy; - if (b >> dx >> dy) { - printf("-- yei!\n"); - // TODO: protocol should carry the player index per peer; - // hard-coded to 0 until the wire format is fleshed out. - server::Msg_MouseForce(0, ecl::V2(dx, dy)); - } + if (b >> dx >> dy) + server::Msg_MouseForce(player, ecl::V2(dx, dy)); break; } - - case server::SVMSG_ACTIVATEITEM: { + case CL_ACTIVATE_ITEM: server::Msg_ActivateItem(); break; + case CL_ROTATE_INVENTORY: { + Uint8 dir; + if (b >> dir) + player::RotateInventory(int(Sint8(dir))); + break; } + case CL_COMMAND: { + std::string cmd; + if (b >> cmd) + server::Msg_Command(cmd); + break; + } + case CL_INHIBIT_PICKUP: { + Uint8 onoff; + if (b >> onoff) + player::InhibitPickup(player, onoff != 0); + break; + } + default: + enigma::Log << "netgame: unknown CL tag 0x" << std::hex << int(tag) << "\n"; + return; + } + } +} - default: enigma::Log << "SV: received undefined packet: " << int(code) << "\n"; +// Parse a packet that arrived on a remote client and apply it to the +// local state. Goes through the existing client::Msg_* and engine +// APIs; since no NetworkSink is registered on the remote, this does +// not re-broadcast. +void dispatch_event_from_server(ecl::Buffer &b) { + Uint8 tag; + while (b >> tag) { + switch (tag) { + case SV_COMMAND: { + std::string cmd; + if (b >> cmd) client::Msg_Command(cmd); + break; + } + case SV_ADVANCE_LEVEL: { + Uint8 mode; + if (b >> mode) client::Msg_AdvanceLevel(lev::LevelAdvanceMode(mode)); + break; + } + case SV_JUMP_BACK: + client::Msg_JumpBack(); + break; + case SV_LEVEL_LOADED: { + Uint8 restart; + if (b >> restart) client::Msg_LevelLoaded(restart != 0); + break; + } + case SV_PLAYER_POSITION: { + Uint8 ip; float x, y; + if (b >> ip >> x >> y) client::Msg_PlayerPosition(ip, ecl::V2(x, y)); + break; + } + case SV_SPARKLE: { + float x, y; + if (b >> x >> y) client::Msg_Sparkle(ecl::V2(x, y)); + break; + } + case SV_PLAY_SOUND: { + std::string sn; float x, y; double v; + if (b >> sn >> x >> y >> v) + client::Msg_PlaySound(sn, ecl::V2(x, y), v); + break; + } + case SV_PLAY_SOUND_REL: { + std::string sn; double v; + if (b >> sn >> v) client::Msg_PlaySound(sn, v); + break; + } + case SV_SHOW_TEXT: { + std::string text; Uint8 scr; double dur; + if (b >> text >> scr >> dur) + client::Msg_ShowText(text, scr != 0, dur); + break; + } + case SV_SHOW_DOCUMENT: { + std::string text; Uint8 scr; double dur; + if (b >> text >> scr >> dur) + client::Msg_ShowDocument(text, scr != 0, dur); + break; + } + case SV_FINISHED_TEXT: + client::Msg_FinishedText(); + break; + case SV_TEATIME: { + Uint8 on; + if (b >> on) client::Msg_Teatime(on != 0); + break; + } + case SV_ERROR: { + std::string text; + if (b >> text) client::Msg_Error(text); + break; + } + case SV_ACTOR_MOVED: { + Uint32 id; float px, py, vx, vy; + if (b >> id >> px >> py >> vx >> vy) { + if (Actor *a = dynamic_cast(Object::getObject(int(id)))) { + ActorInfo *ai = a->get_actorinfo(); + ai->pos = ecl::V2(px, py); + ai->vel = ecl::V2(vx, vy); + a->move_screen(); + } + } + break; + } + case SV_ACTOR_SPRITE: { + Uint32 id; std::string model; + if (b >> id >> model) { + if (Actor *a = dynamic_cast(Object::getObject(int(id)))) + a->set_model(model); + } + break; + } + default: + enigma::Log << "netgame: unknown SV tag 0x" << std::hex << int(tag) << "\n"; + return; } } } +} // anonymous namespace + namespace { Uint32 last_tick_time; @@ -103,9 +303,10 @@ Uint32 last_tick_time; void server_loop(Peer *m_peer) { printf("SV: Entered server loop\n"); server::InitNewGame(); - ecl::Buffer buf; - buf << client::Cl_LevelLoaded(); - m_peer->send_reliable(buf, 1); + + // The remote is assigned player 1; the host drives player 0. + NetworkSink sink(m_peer); + client::RegisterEventSink(&sink); double dtime = 0; while (!client::AbortGameP() && m_peer->is_connected()) { @@ -128,10 +329,11 @@ void server_loop(Peer *m_peer) { ecl::Buffer buf; int player_no; while (m_peer->poll_message(buf, player_no)) { - printf("SV: Received message from client %d\n", player_no); - handle_client_packet(buf, player_no); + dispatch_input_from_client(buf, player_no); } + sink.flush(); + int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); if (sleeptime >= 3) // only sleep if relatively idle SDL_Delay(sleeptime); @@ -147,6 +349,7 @@ void server_loop(Peer *m_peer) { } done: + client::UnregisterEventSink(&sink); return; } @@ -216,22 +419,6 @@ Peer *server_peer; } // namespace -void handle_server_packet(ecl::Buffer &buf) { - if (buf.size() > 0) { - Uint8 code; - buf >> code; - switch (code) { - case client::CLMSG_LEVEL_LOADED: printf("cl_level_loaded\n"); break; - } - } - - ecl::Buffer obuf; - obuf << Uint8(server::SVMSG_LOADLEVEL); - obuf << Uint16(84); - obuf << std::string("Enigma"); - server_peer->send_reliable(obuf, 1); - printf("CL: sending message %u\n", (unsigned)obuf.size()); -} void netgame::Join(std::string hostname, int port) { printf("CL: trying to join remote game\n"); @@ -283,24 +470,28 @@ void netgame::Join(std::string hostname, int port) { } else return; + ecl::Buffer out_unreliable; while (server_peer->is_connected()) { SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type && e.key.keysym.sym == SDLK_ESCAPE) goto done; else if (e.type == SDL_MOUSEMOTION) { - ecl::Buffer buf; float mouseforce = options::GetDouble("MouseSpeed"); - buf << Uint8(server::SVMSG_MOUSEFORCE) << float(e.motion.xrel * mouseforce) - << float(e.motion.yrel * mouseforce); - server_peer->send_reliable(buf, 1); + out_unreliable << Uint8(CL_MOUSE_FORCE) + << float(e.motion.xrel * mouseforce) + << float(e.motion.yrel * mouseforce); } } + if (out_unreliable.size() > 0) { + server_peer->send_message(out_unreliable, CHANNEL_UNRELIABLE); + out_unreliable.clear(); + } ecl::Buffer buf; int peerno; while (server_peer->poll_message(buf, peerno)) { - handle_server_packet(buf); + dispatch_event_from_server(buf); } SDL_Delay(10); } From ce5eaa9b6908289bca0aa1458450568a10412b93 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 08:29:12 +0200 Subject: [PATCH 04/13] fix two latent ecl::Buffer state bugs Two flaws in the vendored buffer library that didn't matter while it was only used for tiny stub packets, but break a real packetized protocol: * Buffer::read() returned -1 at EOF without setting FAILBIT/EOFBIT. A loop like `while (b >> tag)` on a Uint8 read past the end never fails: operator void*() reads FAILBIT and stays truthy, the byte variable keeps its last value, and the dispatcher spins forever on a no-payload tag (e.g. activate-item). * Buffer::clear() reset the byte cursor but not iostate. Once any prior read set FAILBIT, every subsequent assign() (which goes through clear()) inherited the failure, and every fresh packet was silently undispatchable -- so reliable events after the first one consumed to EOF were dropped. Fix both: read() now sets FAILBIT|EOFBIT on overflow, and clear() resets iostate to GOODBIT. --- lib-src/enigma-core/ecl_buffer.cc | 4 ++-- lib-src/enigma-core/ecl_buffer.hh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib-src/enigma-core/ecl_buffer.cc b/lib-src/enigma-core/ecl_buffer.cc index fdc2b524b..9ec0f3af5 100644 --- a/lib-src/enigma-core/ecl_buffer.cc +++ b/lib-src/enigma-core/ecl_buffer.cc @@ -62,8 +62,8 @@ char* Buffer::get_rspace(size_t len) { int Buffer::read() { if (good() && rpos < buf + sz) return *rpos++; - else - return -1; + iostate = State(iostate | FAILBIT | EOFBIT); + return -1; } Buffer& Buffer::write(char c) { diff --git a/lib-src/enigma-core/ecl_buffer.hh b/lib-src/enigma-core/ecl_buffer.hh index fda6a3a3b..111a21a91 100644 --- a/lib-src/enigma-core/ecl_buffer.hh +++ b/lib-src/enigma-core/ecl_buffer.hh @@ -33,6 +33,7 @@ public: void clear() { sz = 0; rpos = wpos = buf; + iostate = GOODBIT; } void assign(char* data, size_t size); From 715af31af401b656c58b47432355b92ddea848fb Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 08:30:16 +0200 Subject: [PATCH 05/13] end-to-end LAN two-player play Two enigma binaries can now connect over ENet, load the same level, and play it together: host drives the black marble, joiner drives the white. Tested locally with two windows on the same machine. Wire protocol (in netgame.cc): * Tags partitioned into 0x10..0x7F for client->host inputs and 0x80+ for host->client events, plus CL_HELLO/SV_HELLO for the handshake. Two ENet channels: 0 unreliable for per-tick state, 1 reliable for everything else. * SV_HELLO ships protocol version, level pack name, level index, RNG seed, and the assigned player number; CL_HELLO acks with the protocol version. The remote then setCurrentIndex / setCurrentPosition / RandomState / Msg_LoadLevel / SetCurrentPlayer (=1) so both sides have the same world before the game starts. * NetworkSink (a client::EventSink subclass) batches outbound events into per-channel buffers and ships them once per host tick. * dispatch_input_from_client (host side) and dispatch_event_from_server (client side) parse and apply. Engine taps so the protocol has something to ship: * World::move_actors emits OnActorMoved per actor per tick, keyed by the actor's index in the live actorlist (not Object id, which isn't reproducible across peers without prior level history). * Actor::set_model emits OnActorSpriteChanged, also by index. * display::SetModel(GridLoc, string) emits OnGridSpriteChanged keyed by (layer, x, y) -- covers stones/floors/items state changes including turnstile rotations. * display::KillModel emits OnGridSpriteCleared so cells emptied by stone removal (e.g. the cell a turnstile arm vacates) are cleared on the remote too. Loops (netgame.cc): * host_main_loop: registers NetworkSink, runs client::Tick + server::Tick, polls the remote's inputs each frame (dispatch_input_from_client with player=1), flushes the sink. * remote_main_loop: client::Tick only (no physics), polls inbound events, flushes batched local input. Local input routing (client.cc): * netgame::IsClient() / IsActive() let the client know it's a remote session; mouse motion, item activation, inventory rotate, and pickup-inhibit fall through netgame::SendInput* helpers to the host instead of the local server::Msg_*. * In an active LAN session, focus loss no longer pops the in-game menu (the other peer keeps simulating, so popping a local pause looks like a freeze). UX: * MainMenu unhides the Network Game button (was gated on ENABLE_EXPERIMENTAL). * The visible mouse cursor's saved-background snapshot is refreshed at the cls_preparing_game -> cls_game transition via the new MouseCursor::recapture_background, so the first cursor move on the joining client doesn't paint stale menu pixels into the loaded level. * Mouse stays ungrabbed and visible during a network session so the user can move between two windows for testing. Object::getNextIdSnapshot / setNextId are added but no longer load-bearing; switching from object id to actorlist index made them unnecessary. Left in place for now; can be removed later. Known gaps for follow-up: sounds emitted directly via sound::EmitSoundEvent (rather than client::Msg_PlaySound) don't sync; keyboard inputs (suicide, restart, etc.) aren't forwarded; the host's "current level" picker is just whatever was last selected in the level menu. --- src/MouseCursor.cc | 10 + src/MouseCursor.hh | 5 + src/Object.cc | 8 + src/Object.hh | 5 + src/actors.cc | 4 +- src/client.cc | 61 ++++-- src/client.hh | 6 + src/display.cc | 2 + src/gui/MainMenu.cc | 4 - src/netgame.cc | 494 ++++++++++++++++++++++++++++++++------------ src/netgame.hh | 19 ++ src/video.cc | 5 + src/video.hh | 1 + src/world.cc | 16 +- src/world.hh | 7 + 15 files changed, 494 insertions(+), 153 deletions(-) diff --git a/src/MouseCursor.cc b/src/MouseCursor.cc index 8fd5a93ba..4d4361f3e 100644 --- a/src/MouseCursor.cc +++ b/src/MouseCursor.cc @@ -74,6 +74,16 @@ void MouseCursor::hide() { } } +void MouseCursor::recapture_background() { + if (visible > 0) { + if (!background) + init_bg(); + else + grab_bg(); + changed = true; + } +} + Rect MouseCursor::get_rect() const { return Rect(x - hotx, y - hoty, cursor->width(), cursor->height()); } diff --git a/src/MouseCursor.hh b/src/MouseCursor.hh index 3f12ee9ed..43bacc0a6 100644 --- a/src/MouseCursor.hh +++ b/src/MouseCursor.hh @@ -38,6 +38,11 @@ public: void draw(); // Draw cursor if visible void show(); void hide(); + // Re-capture the screen pixels under the cursor without first + // restoring the (possibly stale) saved copy. Use this after a + // screen-wide repaint (e.g. menu->game transition) so the next + // cursor move doesn't restore stale pixels. + void recapture_background(); ecl::Rect get_rect() const; ecl::Rect get_oldrect() const; diff --git a/src/Object.cc b/src/Object.cc index fcb0ef726..0869da880 100644 --- a/src/Object.cc +++ b/src/Object.cc @@ -98,6 +98,14 @@ int Object::getId() const { return id; } +int Object::getNextIdSnapshot() { + return next_id; +} + +void Object::setNextId(int v) { + next_id = v; +} + std::string Object::getKind() const { return ObjectValidator::instance()->getKind(this); } diff --git a/src/Object.hh b/src/Object.hh index f86813a8d..104f44d89 100644 --- a/src/Object.hh +++ b/src/Object.hh @@ -94,6 +94,11 @@ public: static Object *getObject(int id); int getId() const; + // Snapshot/restore the global id counter. Used to keep two ENet + // peers' object ids in lock-step across a network level load. + static int getNextIdSnapshot(); + static void setNextId(int v); + /* ---------- depreceated methods ---------- */ const AttribMap &get_attribs() const { diff --git a/src/actors.cc b/src/actors.cc index a5e3c08d3..384b665e7 100644 --- a/src/actors.cc +++ b/src/actors.cc @@ -313,7 +313,9 @@ void Actor::move_screen() { void Actor::set_model(const std::string &name) { m_sprite.replace_model(display::MakeModel(name)); - client::NotifyActorSpriteChanged(getId(), name); + int idx = FindActorIndex(this); + if (idx >= 0) + client::NotifyActorSpriteChanged(idx, name); } void Actor::animcb() { diff --git a/src/client.cc b/src/client.cc index f2c530e0d..37d7d82c3 100644 --- a/src/client.cc +++ b/src/client.cc @@ -39,6 +39,7 @@ #include "lev/Proxy.hh" #include "lev/RatingManager.hh" #include "lev/ScoreManager.hh" +#include "netgame.hh" #include "ecl_font.hh" #include "ecl_sdl.hh" @@ -125,6 +126,16 @@ void NotifyActorSpriteChanged(int actor_id, const std::string &model_name) { for (auto *s : event_sinks) s->OnActorSpriteChanged(actor_id, model_name); } +void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name) { + for (auto *s : event_sinks) + s->OnGridSpriteChanged(layer, x, y, model_name); +} + +void NotifyGridSpriteCleared(int layer, int x, int y) { + for (auto *s : event_sinks) + s->OnGridSpriteCleared(layer, x, y); +} + /* -------------------- Client class -------------------- */ Client::Client() @@ -230,10 +241,14 @@ void Client::handle_events() { break; if (abs(e.motion.xrel) > 300 || abs(e.motion.yrel) > 300) { fprintf(stderr, "mouse event with %i, %i\n", e.motion.xrel, e.motion.yrel); - } else - server::Msg_MouseForce(player::CurrentPlayer(), - options::GetDouble("MouseSpeed") * - ecl::V2(e.motion.xrel, e.motion.yrel)); + } else { + ecl::V2 f = options::GetDouble("MouseSpeed") * + ecl::V2(e.motion.xrel, e.motion.yrel); + if (netgame::IsClient()) + netgame::SendInputMouseForce(f); + else + server::Msg_MouseForce(player::CurrentPlayer(), f); + } break; case SDL_MOUSEBUTTONDOWN: case SDL_MOUSEBUTTONUP: on_mousebutton(e); break; @@ -246,9 +261,11 @@ void Client::handle_events() { case SDL_WINDOWEVENT: { update_mouse_button_state(); if (e.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { - // TODO(SDL2): is this sthe right event? The old code had - // !video::IsFullScreen() as an additional check - necessary? - show_menu(false); + // Don't auto-pause in a network game: the other peer + // keeps simulating, so popping a local menu just blocks + // input on this side and looks like a freeze. + if (!netgame::IsActive()) + show_menu(false); } else if (e.window.event == SDL_WINDOWEVENT_EXPOSED) { display::RedrawAll(video_engine->GetScreen()); } @@ -277,9 +294,11 @@ void Client::handle_events_teatime() { case SDL_WINDOWEVENT: { update_mouse_button_state(); if (e.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { - // TODO(SDL2): is this sthe right event? The old code had - // !video::IsFullScreen() as an additional check - necessary? - show_menu(false); + // Don't auto-pause in a network game: the other peer + // keeps simulating, so popping a local menu just blocks + // input on this side and looks like a freeze. + if (!netgame::IsActive()) + show_menu(false); } else if (e.window.event == SDL_WINDOWEVENT_EXPOSED) { display::RedrawAll(video_engine->GetScreen()); } @@ -295,14 +314,21 @@ void Client::handle_events_teatime() { void Client::update_mouse_button_state() { int b = SDL_GetMouseState(0, 0); - player::InhibitPickup((b & SDL_BUTTON_LMASK) || (b & SDL_BUTTON_RMASK)); + bool inhibit = (b & SDL_BUTTON_LMASK) || (b & SDL_BUTTON_RMASK); + if (netgame::IsClient()) + netgame::SendInputInhibitPickup(inhibit); + else + player::InhibitPickup(inhibit); } void Client::on_mousebutton(SDL_Event &e) { if (e.button.state == SDL_PRESSED) { if (e.button.button == SDL_BUTTON_LEFT) { // left mousebutton -> activate first item in inventory - server::Msg_ActivateItem(); + if (netgame::IsClient()) + netgame::SendInputActivateItem(); + else + server::Msg_ActivateItem(); } else if (e.button.button == SDL_BUTTON_RIGHT) { // right mousebutton -> rotate inventory rotate_inventory(+1); @@ -347,7 +373,10 @@ void Client::on_mousebutton(SDL_Event &e) { void Client::rotate_inventory(int direction) { m_user_input = ""; display::GetStatusBar()->hide_text(); - player::RotateInventory(direction); + if (netgame::IsClient()) + netgame::SendInputRotateInventory(direction); + else + player::RotateInventory(direction); } /* -------------------- Console related -------------------- */ @@ -722,6 +751,12 @@ void Client::tick(double dtime) { m_timeaccu = 0; m_total_game_time = 0; sdl::FlushEvents(); + // The level transition has just finished and the screen + // looks completely different from when the cursor last + // saved its underlying pixels. Refresh that snapshot so + // the next mouse move doesn't paint stale menu pixels + // into the game view. + video_engine->RecaptureMouseBackground(); } break; } diff --git a/src/client.hh b/src/client.hh index 1714aa4c5..5bed39318 100644 --- a/src/client.hh +++ b/src/client.hh @@ -69,6 +69,10 @@ public: virtual void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) {} virtual void OnActorSpriteChanged(int actor_id, const std::string &model_name) {} + // layer is GRID_FLOOR / GRID_ITEMS / GRID_STONES. + virtual void OnGridSpriteChanged(int layer, int x, int y, + const std::string &model_name) {} + virtual void OnGridSpriteCleared(int layer, int x, int y) {} }; void RegisterEventSink(EventSink *sink); @@ -78,6 +82,8 @@ void UnregisterEventSink(EventSink *sink); // a Msg_* function. No local side effect; purely a tap for sinks. void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel); void NotifyActorSpriteChanged(int actor_id, const std::string &model_name); +void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name); +void NotifyGridSpriteCleared(int layer, int x, int y); /* -------------------- Server->Client messages -------------------- */ diff --git a/src/display.cc b/src/display.cc index 29ebfc4c7..2f17fe2fd 100644 --- a/src/display.cc +++ b/src/display.cc @@ -1941,10 +1941,12 @@ Model *display::SetModel(const GridLoc &l, Model *m) { } Model *display::SetModel(const GridLoc &l, const string &modelname) { + client::NotifyGridSpriteChanged(int(l.layer), l.pos.x, l.pos.y, modelname); return SetModel(l, MakeModel(modelname)); } void display::KillModel(const GridLoc &l) { + client::NotifyGridSpriteCleared(int(l.layer), l.pos.x, l.pos.y); delete YieldModel(l); } diff --git a/src/gui/MainMenu.cc b/src/gui/MainMenu.cc index 340c3323a..ab6124d03 100644 --- a/src/gui/MainMenu.cc +++ b/src/gui/MainMenu.cc @@ -450,9 +450,7 @@ namespace enigma { namespace gui { BuildVList *brp = vsmall ? &br : &b; startgame = b.add(new StaticTextButton(N_("Start Game"), this)); levelpack = b.add(new StaticTextButton(N_("Level Pack"), this)); -#ifdef ENABLE_EXPERIMENTAL m_netgame = b.add(new StaticTextButton(N_("Network Game"), this)); -#endif search = b.add(new StaticTextButton(N_("Search"), this)); options = brp->add(new StaticTextButton(N_("Options"), this)); #if 0 @@ -552,10 +550,8 @@ namespace enigma { namespace gui { MainHelpMenu m; m.manage(); - #ifdef ENABLE_EXPERIMENTAL } else if (w == m_netgame) { ShowNetworkMenu(); - #endif } else if (w == quit) { Menu::quit(); } else if (w == languagemenu) { diff --git a/src/netgame.cc b/src/netgame.cc index 05581ff1b..0da1feaa5 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -19,17 +19,24 @@ #include "errors.hh" #include "actors.hh" #include "client.hh" +#include "display.hh" #include "main.hh" #include "netgame.hh" #include "network.hh" #include "options.hh" #include "player.hh" #include "server.hh" +#include "video.hh" +#include "world.hh" + +#include "lev/Index.hh" +#include "lev/Proxy.hh" #include "enet/enet.h" #include "enet_ver.hh" #include "SDL.h" +#include #include using namespace enigma; @@ -42,11 +49,15 @@ using namespace enigma; namespace { +constexpr Uint16 PROTO_VERSION = 1; +constexpr int NET_PORT = 12345; + // Packet tags. Client -> host inputs live in the 0x10–0x7F band, host // -> client events in 0x80+. The bands are separate so a malformed // peer cannot confuse one for the other. enum ProtoTag : Uint8 { // Client -> host (inputs) + CL_HELLO = 0x01, CL_MOUSE_FORCE = 0x10, CL_ACTIVATE_ITEM = 0x11, CL_ROTATE_INVENTORY = 0x12, @@ -54,6 +65,7 @@ enum ProtoTag : Uint8 { CL_INHIBIT_PICKUP = 0x14, // Host -> client (outbound EventSink stream) + SV_HELLO = 0x7F, SV_COMMAND = 0x80, SV_ADVANCE_LEVEL = 0x81, SV_JUMP_BACK = 0x82, @@ -69,6 +81,8 @@ enum ProtoTag : Uint8 { SV_ERROR = 0x8C, SV_ACTOR_MOVED = 0x8D, SV_ACTOR_SPRITE = 0x8E, + SV_GRID_SPRITE = 0x8F, + SV_GRID_KILL = 0x90, }; constexpr int CHANNEL_UNRELIABLE = 0; @@ -134,6 +148,15 @@ class NetworkSink : public client::EventSink { void OnActorSpriteChanged(int actor_id, const std::string &model_name) override { m_reliable << Uint8(SV_ACTOR_SPRITE) << Uint32(actor_id) << model_name; } + void OnGridSpriteChanged(int layer, int x, int y, + const std::string &model_name) override { + m_reliable << Uint8(SV_GRID_SPRITE) << Uint8(layer) + << Uint16(x) << Uint16(y) << model_name; + } + void OnGridSpriteCleared(int layer, int x, int y) override { + m_reliable << Uint8(SV_GRID_KILL) << Uint8(layer) + << Uint16(x) << Uint16(y); + } void flush() { if (m_reliable.size() > 0) { @@ -266,25 +289,43 @@ void dispatch_event_from_server(ecl::Buffer &b) { break; } case SV_ACTOR_MOVED: { - Uint32 id; float px, py, vx, vy; - if (b >> id >> px >> py >> vx >> vy) { - if (Actor *a = dynamic_cast(Object::getObject(int(id)))) { + Uint32 idx; float px, py, vx, vy; + if (b >> idx >> px >> py >> vx >> vy) { + if (Actor *a = GetActorByIndex(int(idx))) { ActorInfo *ai = a->get_actorinfo(); ai->pos = ecl::V2(px, py); ai->vel = ecl::V2(vx, vy); a->move_screen(); + } else { + static int miss_count = 0; + if (++miss_count <= 5) + fprintf(stderr, "CL: SV_ACTOR_MOVED for unknown index %u\n", + unsigned(idx)); } } break; } case SV_ACTOR_SPRITE: { - Uint32 id; std::string model; - if (b >> id >> model) { - if (Actor *a = dynamic_cast(Object::getObject(int(id)))) + Uint32 idx; std::string model; + if (b >> idx >> model) { + if (Actor *a = GetActorByIndex(int(idx))) a->set_model(model); } break; } + case SV_GRID_SPRITE: { + Uint8 layer; Uint16 x, y; std::string model; + if (b >> layer >> x >> y >> model) + display::SetModel(GridLoc(GridLayer(layer), GridPos(int(x), int(y))), + model); + break; + } + case SV_GRID_KILL: { + Uint8 layer; Uint16 x, y; + if (b >> layer >> x >> y) + display::KillModel(GridLoc(GridLayer(layer), GridPos(int(x), int(y)))); + break; + } default: enigma::Log << "netgame: unknown SV tag 0x" << std::hex << int(tag) << "\n"; return; @@ -294,30 +335,108 @@ void dispatch_event_from_server(ecl::Buffer &b) { } // anonymous namespace +//====================================================================== +// Client-session state and input helpers +//====================================================================== + namespace { -Uint32 last_tick_time; +bool s_in_session = false; // host or client +bool s_in_client_session = false; // client only +Peer *s_client_peer = nullptr; +ecl::Buffer s_client_out_unreliable; +ecl::Buffer s_client_out_reliable; + +void flush_client_outbox() { + if (s_client_out_unreliable.size() > 0 && s_client_peer) { + s_client_peer->send_message(s_client_out_unreliable, CHANNEL_UNRELIABLE); + s_client_out_unreliable.clear(); + } + if (s_client_out_reliable.size() > 0 && s_client_peer) { + s_client_peer->send_reliable(s_client_out_reliable, CHANNEL_RELIABLE); + s_client_out_reliable.clear(); + } +} } // namespace -void server_loop(Peer *m_peer) { - printf("SV: Entered server loop\n"); - server::InitNewGame(); +bool netgame::IsClient() { + return s_in_client_session; +} + +bool netgame::IsActive() { + return s_in_session; +} + +void netgame::SendInputMouseForce(const ecl::V2 &f) { + if (!s_in_client_session) return; + s_client_out_unreliable << Uint8(CL_MOUSE_FORCE) << float(f[0]) << float(f[1]); +} + +void netgame::SendInputActivateItem() { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ACTIVATE_ITEM); +} + +void netgame::SendInputRotateInventory(int dir) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ROTATE_INVENTORY) << Uint8(Sint8(dir)); +} + +void netgame::SendInputCommand(const std::string &cmd) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_COMMAND) << cmd; +} + +void netgame::SendInputInhibitPickup(bool onoff) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_INHIBIT_PICKUP) << Uint8(onoff ? 1 : 0); +} + +//====================================================================== +// Handshake and main loops +//====================================================================== - // The remote is assigned player 1; the host drives player 0. - NetworkSink sink(m_peer); +namespace { + +void log_player_actor_ids(const char *who) { + for (unsigned p = 0; p < 2; ++p) { + Actor *a = player::GetMainActor(p); + if (a) + fprintf(stderr, "%s: player %u main actor id=%d index=%d\n", + who, p, a->getId(), FindActorIndex(a)); + else + fprintf(stderr, "%s: player %u has no main actor\n", who, p); + } +} + +// Wait up to `timeout_ms` for a single packet to arrive. Returns true +// on success (with the packet's bytes in `out`), false on timeout or +// disconnect. +bool wait_for_packet(Peer *peer, ecl::Buffer &out, int timeout_ms) { + Uint32 start = SDL_GetTicks(); + while (peer->is_connected()) { + int dummy; + if (peer->poll_message(out, dummy)) + return true; + if ((Uint32)(SDL_GetTicks() - start) >= (Uint32)timeout_ms) + return false; + SDL_Delay(5); + } + return false; +} + +// Run the host's main loop: full simulation, plus broadcast every +// EventSink hit and consume the remote's inputs each tick. +void host_main_loop(Peer *peer) { + NetworkSink sink(peer); client::RegisterEventSink(&sink); + Uint32 last_tick_time = SDL_GetTicks(); double dtime = 0; - while (!client::AbortGameP() && m_peer->is_connected()) { + while (!client::AbortGameP() && peer->is_connected() && !app.bossKeyPressed) { last_tick_time = SDL_GetTicks(); - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) - goto done; - } - try { client::Tick(dtime); server::Tick(dtime); @@ -327,178 +446,285 @@ void server_loop(Peer *m_peer) { } ecl::Buffer buf; - int player_no; - while (m_peer->poll_message(buf, player_no)) { - dispatch_input_from_client(buf, player_no); - } + int dummy; + while (peer->poll_message(buf, dummy)) + dispatch_input_from_client(buf, /*player=*/1); sink.flush(); int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); - if (sleeptime >= 3) // only sleep if relatively idle + if (sleeptime >= 3) SDL_Delay(sleeptime); - dtime = (SDL_GetTicks() - last_tick_time) / 1000.0; - if (fabs(1 - dtime / 0.01) < 0.2) { - // less than 20% deviation from desired frame time? + Uint32 now = SDL_GetTicks(); + dtime = (now - last_tick_time) / 1000.0; + if (fabs(1 - dtime / 0.01) < 0.2) dtime = 0.01; - } - - if (dtime > 500.0) /* Time has done something strange, perhaps - run backwards */ + if (dtime > 500.0) dtime = 0.0; } -done: client::UnregisterEventSink(&sink); - return; } +// Run the remote's main loop: no physics, just consume state from the +// host and forward local inputs. client::Tick still runs so the local +// display, sound, and follower keep up. +void remote_main_loop(Peer *peer) { + Uint32 last_tick_time = SDL_GetTicks(); + double dtime = 0; + while (!client::AbortGameP() && peer->is_connected() && !app.bossKeyPressed) { + last_tick_time = SDL_GetTicks(); + + try { + client::Tick(dtime); + } catch (XLevelRuntime &err) { + client::Msg_Error(std::string("Client Error: level runtime error:\n") + err.what()); + break; + } + + ecl::Buffer buf; + int dummy; + while (peer->poll_message(buf, dummy)) + dispatch_event_from_server(buf); + + flush_client_outbox(); + + int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); + if (sleeptime >= 3) + SDL_Delay(sleeptime); + Uint32 now = SDL_GetTicks(); + dtime = (now - last_tick_time) / 1000.0; + if (fabs(1 - dtime / 0.01) < 0.2) + dtime = 0.01; + if (dtime > 500.0) + dtime = 0.0; + } +} + +} // anonymous namespace + void netgame::Start() { + // Host plays whatever level the user has currently selected in the + // level browser. A proper lobby (host/port/level/color picker) is + // still missing. + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind == nullptr) { + fprintf(stderr, "SV: no current level pack selected.\n"); + return; + } + lev::Proxy *proxy = ind->getCurrent(); + if (proxy == nullptr) { + fprintf(stderr, "SV: no current level selected.\n"); + return; + } - // ---------- Create network host ---------- - ENetHost *network_host; ENetAddress network_address; - network_address.host = ENET_HOST_ANY; - network_address.port = 12345; + network_address.port = NET_PORT; + ENetHost *network_host = #ifdef ENET_VER_EQ_GT_13 - network_host = enet_host_create(&network_address, 1, 0, 0, 0); + enet_host_create(&network_address, 1, 2, 0, 0); #else - network_host = enet_host_create(&network_address, 1, 0, 0); + enet_host_create(&network_address, 1, 0, 0); #endif - if (network_host == NULL) { - fprintf(stderr, "SV: An error occurred while trying to create an ENet server host.\n"); + if (network_host == nullptr) { + fprintf(stderr, "SV: failed to create an ENet host on port %d.\n", NET_PORT); return; } - // ---------- Wait for client(s) ---------- - ENetEvent event; - Peer *m_peer = 0; - printf("SV: Waiting for client...\n"); - - while (!m_peer) { + Peer *peer = nullptr; + printf("SV: waiting for client on port %d (press ESC to cancel)...\n", NET_PORT); + while (peer == nullptr) { SDL_Event e; while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) + if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) { + enet_host_destroy(network_host); return; + } } - + ENetEvent event; while (enet_host_service(network_host, &event, 0) > 0) { if (event.type == ENET_EVENT_TYPE_CONNECT) { - printf("SV: Connected to client\n"); - m_peer = new Peer_Enet(network_host, event.peer, 2); + printf("SV: client connected.\n"); + peer = new Peer_Enet(network_host, event.peer, 2); } } SDL_Delay(10); } - server_loop(m_peer); + // Handshake. Pick a fresh RNG seed and ship the level coordinates, + // plus the host's current Object::next_id snapshot so the remote + // can sync its id allocator and end up with matching actor ids. + Uint32 seed = Uint32(SDL_GetTicks()) ^ Uint32(uintptr_t(peer)); + Uint32 next_id_snapshot = Uint32(Object::getNextIdSnapshot()); + { + ecl::Buffer hello; + hello << Uint8(SV_HELLO) + << PROTO_VERSION + << ind->getName() + << Uint32(ind->getCurrentPosition()) + << seed + << next_id_snapshot + << Uint8(1); // remote is player 1 + peer->send_reliable(hello, CHANNEL_RELIABLE); + } + ecl::Buffer reply; + if (!wait_for_packet(peer, reply, /*timeout_ms=*/10000)) { + fprintf(stderr, "SV: handshake timed out.\n"); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + { + Uint8 tag = 0; + Uint16 cl_proto = 0; + reply >> tag >> cl_proto; + if (tag != CL_HELLO || cl_proto != PROTO_VERSION) { + fprintf(stderr, "SV: handshake mismatch (tag=0x%x proto=%d).\n", tag, int(cl_proto)); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + } - m_peer->disconnect(); - delete m_peer; + // Host loads the level locally with the agreed seed. + server::RandomState = Sint32(seed); + server::Msg_LoadLevel(proxy, false); + log_player_actor_ids("SV"); + + // Cursor stays visible and mouse stays ungrabbed in network mode + // so the user can move between two windows on the same machine + // for testing. This may change once we have a real lobby UX. + s_in_session = true; + host_main_loop(peer); + s_in_session = false; + fprintf(stderr, "SV: host loop exited (connected=%d, abort=%d)\n", + int(peer->is_connected()), int(client::AbortGameP())); + + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); } -//====================================================================== -// CLIENT -//====================================================================== - -namespace { - -struct MovementCommand { - float time_stamp; - float force_x; - float force_y; -}; - -typedef std::list MovementList; - -MovementList movement_list; - -Peer *server_peer; - -} // namespace - - void netgame::Join(std::string hostname, int port) { - printf("CL: trying to join remote game\n"); + if (port <= 0) + port = NET_PORT; + printf("CL: connecting to %s:%d...\n", hostname.c_str(), port); - // ---------- Create network host ---------- - ENetHost *m_network_host; - m_network_host = enet_host_create(NULL, 1 /* only allow 1 outgoing connection */, + ENetHost *network_host = enet_host_create(nullptr, 1, #ifdef ENET_VER_EQ_GT_13 - 2 /* 2 channels are sufficient */, + 2, #endif - 57600 / 8 /* 56K modem with 56 Kbps downstream bandwidth */, - 14400 / 8 /* 56K modem with 14 Kbps upstream bandwidth */); - - if (m_network_host == NULL) { - fprintf(stderr, "CL: An error occurred while trying to create an ENet client host.\n"); + 0, 0); + if (network_host == nullptr) { + fprintf(stderr, "CL: failed to create ENet client host.\n"); return; } - // ---------- Connect to server ---------- - ENetAddress sv_address; - ENetPeer *m_server; - - /* Connect to some.server.net:1234. */ enet_address_set_host(&sv_address, hostname.c_str()); sv_address.port = port; - /* Initiate the connection, allocating the two channels 0 and 1. */ - int numchannels = 2; + ENetPeer *raw_peer = #ifdef ENET_VER_EQ_GT_13 - m_server = enet_host_connect(m_network_host, &sv_address, numchannels, 57600); + enet_host_connect(network_host, &sv_address, 2, 0); #else - m_server = enet_host_connect(m_network_host, &sv_address, numchannels); + enet_host_connect(network_host, &sv_address, 2); #endif - - if (m_server == NULL) { - fprintf(stderr, "CL: No available peers for initiating an ENet connection.\n"); + if (raw_peer == nullptr) { + fprintf(stderr, "CL: no available peers for connection.\n"); + enet_host_destroy(network_host); return; } - server_peer = 0; ENetEvent event; - if (enet_host_service(m_network_host, &event, 5000) > 0 && - event.type == ENET_EVENT_TYPE_CONNECT) { - fprintf(stderr, "CL: Connection to some.server.net:12345 succeeded.\n"); - if (m_server != event.peer) - printf("CL: peers differ!?!\n"); - server_peer = new Peer_Enet(m_network_host, m_server, 0); - } else + if (enet_host_service(network_host, &event, 5000) <= 0 || + event.type != ENET_EVENT_TYPE_CONNECT) { + fprintf(stderr, "CL: connection to %s:%d failed.\n", hostname.c_str(), port); + enet_peer_reset(raw_peer); + enet_host_destroy(network_host); + return; + } + Peer *peer = new Peer_Enet(network_host, raw_peer, 0); + + // Receive SV_HELLO and parse. + ecl::Buffer hello; + if (!wait_for_packet(peer, hello, /*timeout_ms=*/10000)) { + fprintf(stderr, "CL: handshake timed out.\n"); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + Uint8 tag = 0; + Uint16 proto = 0; + std::string level_pack; + Uint32 level_idx = 0; + Uint32 seed = 0; + Uint32 next_id_snapshot = 0; + Uint8 assigned_player = 1; + hello >> tag >> proto >> level_pack >> level_idx >> seed >> next_id_snapshot + >> assigned_player; + if (tag != SV_HELLO || proto != PROTO_VERSION) { + fprintf(stderr, "CL: bad SV_HELLO (tag=0x%x proto=%d).\n", tag, int(proto)); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + printf("CL: host wants level pack '%s' #%d, seed=0x%x, next_id=%u, " + "assigned player=%d\n", + level_pack.c_str(), int(level_idx), unsigned(seed), + unsigned(next_id_snapshot), int(assigned_player)); + + if (!lev::Index::setCurrentIndex(level_pack)) { + fprintf(stderr, "CL: missing level pack '%s'.\n", level_pack.c_str()); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); return; + } + lev::Index *ind = lev::Index::getCurrentIndex(); + if (int(level_idx) >= ind->size()) { + fprintf(stderr, "CL: level pack only has %d levels, host asked for %d.\n", + ind->size(), int(level_idx)); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + ind->setCurrentPosition(int(level_idx)); + + server::RandomState = Sint32(seed); + Object::setNextId(int(next_id_snapshot)); + server::Msg_LoadLevel(ind->getProxy(int(level_idx)), false); + player::SetCurrentPlayer(assigned_player); + log_player_actor_ids("CL"); + + // Acknowledge. + { + ecl::Buffer ack; + ack << Uint8(CL_HELLO) << PROTO_VERSION; + peer->send_reliable(ack, CHANNEL_RELIABLE); + } - ecl::Buffer out_unreliable; - while (server_peer->is_connected()) { - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) - goto done; - else if (e.type == SDL_MOUSEMOTION) { - float mouseforce = options::GetDouble("MouseSpeed"); - out_unreliable << Uint8(CL_MOUSE_FORCE) - << float(e.motion.xrel * mouseforce) - << float(e.motion.yrel * mouseforce); - } - } - if (out_unreliable.size() > 0) { - server_peer->send_message(out_unreliable, CHANNEL_UNRELIABLE); - out_unreliable.clear(); - } + s_in_session = true; + s_in_client_session = true; + s_client_peer = peer; - ecl::Buffer buf; - int peerno; - while (server_peer->poll_message(buf, peerno)) { - dispatch_event_from_server(buf); - } - SDL_Delay(10); - } + remote_main_loop(peer); + fprintf(stderr, "CL: remote loop exited (connected=%d, abort=%d)\n", + int(peer->is_connected()), int(client::AbortGameP())); + + flush_client_outbox(); + s_in_client_session = false; + s_in_session = false; + s_client_peer = nullptr; -done: - server_peer->disconnect(); - delete server_peer; - server_peer = 0; - return; + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); } diff --git a/src/netgame.hh b/src/netgame.hh index a77404ac6..804acf1d1 100644 --- a/src/netgame.hh +++ b/src/netgame.hh @@ -19,6 +19,8 @@ #ifndef NETGAME_HH_INCLUDED #define NETGAME_HH_INCLUDED +#include "ecl_math.hh" + #include namespace netgame { @@ -26,6 +28,23 @@ namespace netgame { void Start(); void Join(std::string hostname, int port); +// True while inside a Join() session (we are a remote client of some +// host). False during Start() and during ordinary single-player play. +bool IsClient(); + +// True while either Start() or Join() is in flight. Used by UI code +// that wants to suppress single-player-only behaviour (mouse grab, +// auto-pause on focus loss, hide cursor) during LAN play. +bool IsActive(); + +// Send-input helpers used by the local UI layer to forward the player's +// inputs to the host. No-ops outside a client session. +void SendInputMouseForce(const ecl::V2 &f); +void SendInputActivateItem(); +void SendInputRotateInventory(int dir); +void SendInputCommand(const std::string &cmd); +void SendInputInhibitPickup(bool onoff); + } // namespace netgame #endif diff --git a/src/video.cc b/src/video.cc index 37f1a9af7..d3dbed55d 100644 --- a/src/video.cc +++ b/src/video.cc @@ -366,6 +366,7 @@ class VideoEngineImpl : public VideoEngine { void SetMouseCursor(ecl::Surface *s, int hotx, int hoty) override; void HideMouse() override; void ShowMouse() override; + void RecaptureMouseBackground() override; int Mousex() override { return cursor->get_x(); } int Mousey() override { return cursor->get_y(); } @@ -755,6 +756,10 @@ void VideoEngineImpl::ShowMouse() { cursor->redraw(); } +void VideoEngineImpl::RecaptureMouseBackground() { + cursor->recapture_background(); +} + bool VideoEngineImpl::OpenWindow(int width, int height, bool fullscreen) { Uint32 flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; if (fullscreen) diff --git a/src/video.hh b/src/video.hh index 11807dff4..857e3af72 100644 --- a/src/video.hh +++ b/src/video.hh @@ -189,6 +189,7 @@ public: virtual void SetMouseCursor(ecl::Surface *s, int hotx, int hoty) = 0; virtual void HideMouse() = 0; virtual void ShowMouse() = 0; + virtual void RecaptureMouseBackground() = 0; virtual int Mousex() = 0; virtual int Mousey() = 0; diff --git a/src/world.cc b/src/world.cc index aaea729ce..8d2b0dc73 100644 --- a/src/world.cc +++ b/src/world.cc @@ -1436,7 +1436,7 @@ void World::move_actors(double dtime) { for (unsigned i = 0; i < nactors; ++i) { Actor *a = actorlist[i]; const ActorInfo &ai = *a->get_actorinfo(); - client::NotifyActorMoved(a->getId(), ai.pos, ai.vel); + client::NotifyActorMoved(int(i), ai.pos, ai.vel); } } @@ -1757,6 +1757,20 @@ V2 GetMouseForceForPlayer(Actor *a, int player) { return level->m_mouseforce.get_force_for_player(a, player); } +int FindActorIndex(Actor *a) { + if (!level) return -1; + for (size_t i = 0; i < level->actorlist.size(); ++i) + if (level->actorlist[i] == a) + return int(i); + return -1; +} + +Actor *GetActorByIndex(int idx) { + if (!level || idx < 0 || size_t(idx) >= level->actorlist.size()) + return nullptr; + return level->actorlist[idx]; +} + void NameObject(Object *obj, const std::string &name) { string oldname; if (Value v = obj->getAttr("name")) { diff --git a/src/world.hh b/src/world.hh index abf4a8b83..3bd781e8c 100644 --- a/src/world.hh +++ b/src/world.hh @@ -251,6 +251,13 @@ unsigned CountActorsOfKind(ActorID id); false if none were found. */ bool GetActorsInRange(ecl::V2 center, double range, std::vector &actors); +/*! Position of `a` in the world's actor list, or -1 if not present. + Stable for the duration of a level (actors are appended on creation + and never reordered), and used as the network identity for actor + updates so two peers don't need matching Object ids. */ +int FindActorIndex(Actor *a); +Actor *GetActorByIndex(int idx); + /*! Find all actors that are inside 'pos'. Returns false if none were found. */ bool GetActorsInsideField(const GridPos &pos, std::vector &actors); From ca1972fe8a0fbcc39caad37728c0ff53680fd2d0 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 09:38:19 +0200 Subject: [PATCH 06/13] broaden state sync over the LAN protocol Sounds, inventory, move counter, and keyboard commands now travel between host and remote, and per-player inventory operations target the right player. Outbound (host -> client): * Tap sound::EmitSoundEvent (the single chokepoint for all 34 in-engine sound sites). New SV_SOUND tag carries name, position, volume, and the global flag. Drops the now-redundant SV_PLAY_SOUND and SV_PLAY_SOUND_REL tags and the matching OnPlaySound / OnPlaySoundRelative hooks; client::Msg_PlaySound just calls sound::EmitSoundEvent and the lower-level tap fans out. * Tap player::RedrawInventory(Inventory*) keyed by the inventory's owner, so each peer can render its own player's inventory bar even when the host's CurrentPlayer is the other one. Adds Inventory::getOwner(). * Tap StatusBarImpl::set_counter, so the remote's stone-move counter no longer freezes at zero. Inbound (remote -> host): * F3 / Shift+F3 / Ctrl+A / Ctrl+Shift+F3 forward as send_command("suicide" | "restart"). F4 / F5 forward as "advance_strict" / "advance_unsolved". F6 forwards as "jumpback". server::Msg_Command grows handlers for the three new tokens. Per-player input routing fix: * player::ActivateFirstItem and player::RotateInventory now take an explicit player index instead of using the host-side icurrent_player. server::Msg_ActivateItem(int iplayer) takes a player too. * The local mouse handlers pass player::CurrentPlayer(); the network dispatcher passes the connection's player index. Without this, when the remote left-clicked, the host activated *its* first item (e.g. the yin-yang), causing both marbles to swap and end up sharing inventory. Also pin and snapshot the level position once at the start of netgame::Start, so a stray index advance (or hover-induced change) between proxy capture and SV_HELLO can't make the host load one level locally and tell the remote to load another. Printfs now display 1-indexed level numbers to match the level menu. --- src/Inventory.hh | 1 + src/SoundEffectManager.cc | 2 + src/client.cc | 58 +++++++++++++++++++++----- src/client.hh | 25 +++++++++-- src/display.cc | 1 + src/netgame.cc | 87 ++++++++++++++++++++++++++++----------- src/player.cc | 29 ++++++++++--- src/player.hh | 3 +- src/server.cc | 10 ++++- src/server.hh | 2 +- 10 files changed, 170 insertions(+), 48 deletions(-) diff --git a/src/Inventory.hh b/src/Inventory.hh index ca44a36ed..7b74655ea 100644 --- a/src/Inventory.hh +++ b/src/Inventory.hh @@ -50,6 +50,7 @@ namespace enigma // ---------- Methods ---------- void assignOwner(int playerId); + int getOwner() const { return ownerId; } //! The number of items currently in the inventory size_t size() const; diff --git a/src/SoundEffectManager.cc b/src/SoundEffectManager.cc index ce8a25cdf..f86180d25 100644 --- a/src/SoundEffectManager.cc +++ b/src/SoundEffectManager.cc @@ -77,11 +77,13 @@ void sound::DefineSoundEffect(std::string soundset_key, std::string name, std::s bool sound::EmitSoundEvent (const std::string &eventname, const ecl::V2 &pos, double volume, bool force_global) { + enigma::client::NotifySound(eventname, pos, volume, force_global); return SoundEffectManager::instance()->emitSoundEvent(eventname, pos, volume, force_global); } bool sound::EmitSoundEventGlobal (const std::string &eventname, double volume) { + enigma::client::NotifySound(eventname, ecl::V2(), volume, true); return SoundEffectManager::instance()->emitSoundEvent(eventname, ecl::V2(), volume, true); } diff --git a/src/client.cc b/src/client.cc index 37d7d82c3..c6c277fcc 100644 --- a/src/client.cc +++ b/src/client.cc @@ -136,6 +136,23 @@ void NotifyGridSpriteCleared(int layer, int x, int y) { s->OnGridSpriteCleared(layer, x, y); } +void NotifySound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) { + for (auto *s : event_sinks) + s->OnSound(soundname, pos, volume, global); +} + +void NotifyInventoryChanged(int player_index, + const std::vector &model_names) { + for (auto *s : event_sinks) + s->OnInventoryChanged(player_index, model_names); +} + +void NotifyMoveCounter(int value) { + for (auto *s : event_sinks) + s->OnMoveCounter(value); +} + /* -------------------- Client class -------------------- */ Client::Client() @@ -328,7 +345,7 @@ void Client::on_mousebutton(SDL_Event &e) { if (netgame::IsClient()) netgame::SendInputActivateItem(); else - server::Msg_ActivateItem(); + server::Msg_ActivateItem(player::CurrentPlayer()); } else if (e.button.button == SDL_BUTTON_RIGHT) { // right mousebutton -> rotate inventory rotate_inventory(+1); @@ -501,14 +518,21 @@ void Client::on_keydown(SDL_Event &e) { SDL_Keycode keysym = e.key.keysym.sym; Uint16 keymod = e.key.keysym.mod; + auto send_command = [](const std::string &cmd) { + if (netgame::IsClient()) + netgame::SendInputCommand(cmd); + else + server::Msg_Command(cmd); + }; + if (keymod & KMOD_CTRL) { switch (keysym) { - case SDLK_a: server::Msg_Command("restart"); break; + case SDLK_a: send_command("restart"); break; case SDLK_F3: if (keymod & KMOD_SHIFT) { // force a reload from file lev::Proxy::releaseCache(); - server::Msg_Command("restart"); + send_command("restart"); } default: break; }; @@ -562,14 +586,29 @@ void Client::on_keydown(SDL_Event &e) { break; case SDLK_F3: if (keymod & KMOD_SHIFT) - server::Msg_Command("restart"); + send_command("restart"); else - server::Msg_Command("suicide"); + send_command("suicide"); break; - case SDLK_F4: Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); break; - case SDLK_F5: Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); break; - case SDLK_F6: Msg_JumpBack(); break; + case SDLK_F4: + if (netgame::IsClient()) + netgame::SendInputCommand("advance_strict"); + else + Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); + break; + case SDLK_F5: + if (netgame::IsClient()) + netgame::SendInputCommand("advance_unsolved"); + else + Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); + break; + case SDLK_F6: + if (netgame::IsClient()) + netgame::SendInputCommand("jumpback"); + else + Msg_JumpBack(); + break; case SDLK_F10: { video_engine->Screenshot(server::LoadedProxy->getNextScreenshotPath()); @@ -1079,12 +1118,11 @@ void Msg_PlayerPosition(unsigned iplayer, const ecl::V2 &pos) { } void Msg_PlaySound(const std::string &wavfile, const ecl::V2 &pos, double relative_volume) { - for (auto *s : event_sinks) s->OnPlaySound(wavfile, pos, relative_volume); + // Broadcast happens via the tap inside sound::EmitSoundEvent. sound::EmitSoundEvent(wavfile.c_str(), pos, relative_volume); } void Msg_PlaySound(const std::string &wavfile, double relative_volume) { - for (auto *s : event_sinks) s->OnPlaySoundRelative(wavfile, relative_volume); sound::EmitSoundEvent(wavfile.c_str(), ecl::V2(), relative_volume); } diff --git a/src/client.hh b/src/client.hh index 5bed39318..92b0f7227 100644 --- a/src/client.hh +++ b/src/client.hh @@ -22,6 +22,7 @@ #include "lev/Index.hh" #include +#include namespace enigma { namespace client { @@ -62,11 +63,24 @@ public: virtual void OnShowDocument(const std::string &text, bool scrolling, double duration) {} virtual void OnFinishedText() {} virtual void OnTeatime(bool onoff) {} - virtual void OnPlaySound(const std::string &soundname, const ecl::V2 &pos, - double relative_volume) {} - virtual void OnPlaySoundRelative(const std::string &soundname, double relative_volume) {} virtual void OnError(const std::string &text) {} + // Every sound::EmitSoundEvent on the host. global=true means the + // sound is positionless; the client should play it without spatial + // attenuation. Replaces the older OnPlaySound* hooks. + virtual void OnSound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) {} + + // The status bar's per-player inventory contents. Fired whenever + // any player's inventory changes (additions, removals, reorders). + // The host fires this for both players; each peer renders the + // side that matches its local CurrentPlayer. + virtual void OnInventoryChanged(int player_index, + const std::vector &model_names) {} + + // The status bar's stone-move counter (sokoban-style score). + virtual void OnMoveCounter(int value) {} + virtual void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) {} virtual void OnActorSpriteChanged(int actor_id, const std::string &model_name) {} // layer is GRID_FLOOR / GRID_ITEMS / GRID_STONES. @@ -84,6 +98,11 @@ void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel); void NotifyActorSpriteChanged(int actor_id, const std::string &model_name); void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name); void NotifyGridSpriteCleared(int layer, int x, int y); +void NotifySound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global); +void NotifyInventoryChanged(int player_index, + const std::vector &model_names); +void NotifyMoveCounter(int value); /* -------------------- Server->Client messages -------------------- */ diff --git a/src/display.cc b/src/display.cc index 2f17fe2fd..0de727e97 100644 --- a/src/display.cc +++ b/src/display.cc @@ -134,6 +134,7 @@ void StatusBarImpl::set_counter(int new_counter) { if (m_showcounter_p && new_counter != m_counter) { m_changedp = true; m_counter = new_counter; + client::NotifyMoveCounter(new_counter); } } diff --git a/src/netgame.cc b/src/netgame.cc index 0da1feaa5..f1245d7e1 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -20,12 +20,14 @@ #include "actors.hh" #include "client.hh" #include "display.hh" +#include "enigma.hh" #include "main.hh" #include "netgame.hh" #include "network.hh" #include "options.hh" #include "player.hh" #include "server.hh" +#include "SoundEffectManager.hh" #include "video.hh" #include "world.hh" @@ -72,8 +74,7 @@ enum ProtoTag : Uint8 { SV_LEVEL_LOADED = 0x83, SV_PLAYER_POSITION = 0x84, SV_SPARKLE = 0x85, - SV_PLAY_SOUND = 0x86, - SV_PLAY_SOUND_REL = 0x87, + SV_SOUND = 0x86, SV_SHOW_TEXT = 0x88, SV_SHOW_DOCUMENT = 0x89, SV_FINISHED_TEXT = 0x8A, @@ -83,6 +84,8 @@ enum ProtoTag : Uint8 { SV_ACTOR_SPRITE = 0x8E, SV_GRID_SPRITE = 0x8F, SV_GRID_KILL = 0x90, + SV_INVENTORY = 0x91, + SV_MOVE_COUNTER = 0x92, }; constexpr int CHANNEL_UNRELIABLE = 0; @@ -128,18 +131,25 @@ class NetworkSink : public client::EventSink { void OnTeatime(bool onoff) override { m_reliable << Uint8(SV_TEATIME) << Uint8(onoff ? 1 : 0); } - void OnPlaySound(const std::string &soundname, const ecl::V2 &pos, - double relative_volume) override { - m_reliable << Uint8(SV_PLAY_SOUND) << soundname - << float(pos[0]) << float(pos[1]) << double(relative_volume); - } - void OnPlaySoundRelative(const std::string &soundname, - double relative_volume) override { - m_reliable << Uint8(SV_PLAY_SOUND_REL) << soundname << double(relative_volume); - } void OnError(const std::string &text) override { m_reliable << Uint8(SV_ERROR) << text; } + void OnSound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) override { + m_reliable << Uint8(SV_SOUND) << soundname + << float(pos[0]) << float(pos[1]) + << double(volume) << Uint8(global ? 1 : 0); + } + void OnInventoryChanged(int player_index, + const std::vector &model_names) override { + m_reliable << Uint8(SV_INVENTORY) << Uint8(player_index) + << Uint16(model_names.size()); + for (auto const &m : model_names) + m_reliable << m; + } + void OnMoveCounter(int value) override { + m_reliable << Uint8(SV_MOVE_COUNTER) << Uint32(value); + } void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) override { m_unreliable << Uint8(SV_ACTOR_MOVED) << Uint32(actor_id) << float(pos[0]) << float(pos[1]) @@ -189,12 +199,12 @@ void dispatch_input_from_client(ecl::Buffer &b, int player) { break; } case CL_ACTIVATE_ITEM: - server::Msg_ActivateItem(); + server::Msg_ActivateItem(player); break; case CL_ROTATE_INVENTORY: { Uint8 dir; if (b >> dir) - player::RotateInventory(int(Sint8(dir))); + player::RotateInventory(player, int(Sint8(dir))); break; } case CL_COMMAND: { @@ -252,15 +262,33 @@ void dispatch_event_from_server(ecl::Buffer &b) { if (b >> x >> y) client::Msg_Sparkle(ecl::V2(x, y)); break; } - case SV_PLAY_SOUND: { - std::string sn; float x, y; double v; - if (b >> sn >> x >> y >> v) - client::Msg_PlaySound(sn, ecl::V2(x, y), v); + case SV_SOUND: { + std::string sn; float x, y; double v; Uint8 global; + if (b >> sn >> x >> y >> v >> global) + sound::EmitSoundEvent(sn, ecl::V2(x, y), v, global != 0); + break; + } + case SV_INVENTORY: { + Uint8 player_index; Uint16 count; + if (b >> player_index >> count) { + std::vector models; + models.reserve(count); + bool ok = true; + for (Uint16 i = 0; i < count && ok; ++i) { + std::string m; + if (b >> m) models.push_back(std::move(m)); + else ok = false; + } + if (ok && int(player_index) == player::CurrentPlayer()) + display::GetStatusBar()->set_inventory( + player_index == 0 ? YIN : YANG, models); + } break; } - case SV_PLAY_SOUND_REL: { - std::string sn; double v; - if (b >> sn >> v) client::Msg_PlaySound(sn, v); + case SV_MOVE_COUNTER: { + Uint32 v; + if (b >> v) + display::GetStatusBar()->set_counter(int(v)); break; } case SV_SHOW_TEXT: { @@ -512,11 +540,18 @@ void netgame::Start() { fprintf(stderr, "SV: no current level pack selected.\n"); return; } - lev::Proxy *proxy = ind->getCurrent(); + // Snapshot position once so the proxy we load and the level number + // we ship to the remote stay consistent even if something else + // would otherwise advance the index in between. + int level_pos = ind->getCurrentPosition(); + lev::Proxy *proxy = ind->getProxy(level_pos); if (proxy == nullptr) { fprintf(stderr, "SV: no current level selected.\n"); return; } + printf("SV: hosting level pack '%s' level %d (%s)\n", + ind->getName().c_str(), level_pos + 1, + proxy->getTitle().c_str()); ENetAddress network_address; network_address.host = ENET_HOST_ANY; @@ -563,7 +598,7 @@ void netgame::Start() { hello << Uint8(SV_HELLO) << PROTO_VERSION << ind->getName() - << Uint32(ind->getCurrentPosition()) + << Uint32(level_pos) << seed << next_id_snapshot << Uint8(1); // remote is player 1 @@ -590,7 +625,9 @@ void netgame::Start() { } } - // Host loads the level locally with the agreed seed. + // Host loads the level locally with the agreed seed. Re-pin the + // index position too in case it slipped between snapshot and now. + ind->setCurrentPosition(level_pos); server::RandomState = Sint32(seed); server::Msg_LoadLevel(proxy, false); log_player_actor_ids("SV"); @@ -675,9 +712,9 @@ void netgame::Join(std::string hostname, int port) { enet_host_destroy(network_host); return; } - printf("CL: host wants level pack '%s' #%d, seed=0x%x, next_id=%u, " + printf("CL: host wants level pack '%s' level %d, seed=0x%x, next_id=%u, " "assigned player=%d\n", - level_pack.c_str(), int(level_idx), unsigned(seed), + level_pack.c_str(), int(level_idx) + 1, unsigned(seed), unsigned(next_id_snapshot), int(assigned_player)); if (!lev::Index::setCurrentIndex(level_pack)) { diff --git a/src/player.cc b/src/player.cc index 66a9ca2f4..f748edf35 100644 --- a/src/player.cc +++ b/src/player.cc @@ -544,8 +544,10 @@ bool player::PickupAsItem(Actor *a, GridObject *obj, std::string kind) { return false; } -void player::ActivateFirstItem() { - Inventory &inv = players[icurrent_player].inventory; +void player::ActivateFirstItem(int iplayer) { + if ((unsigned)iplayer >= players.size()) + return; + Inventory &inv = players[iplayer].inventory; if (inv.size() > 0) { Item *it = inv.get_item(0); @@ -553,8 +555,8 @@ void player::ActivateFirstItem() { GridPos p; bool can_drop_item = false; std::vector::iterator itr; - for (itr = players[icurrent_player].actors.begin(); - itr != players[icurrent_player].actors.end() && ac == nullptr; itr++) { + for (itr = players[iplayer].actors.begin(); + itr != players[iplayer].actors.end() && ac == nullptr; itr++) { if (!(*itr)->is_dead()) { ac = *itr; p = GridPos(ac->get_pos()); @@ -583,8 +585,14 @@ void player::ActivateFirstItem() { } void player::RotateInventory(int dir) { + RotateInventory(CurrentPlayer(), dir); +} + +void player::RotateInventory(int iplayer, int dir) { + if ((unsigned)iplayer >= players.size()) + return; sound::EmitSoundEvent("invrotate", ecl::V2()); - Inventory &inv = players[icurrent_player].inventory; + Inventory &inv = players[iplayer].inventory; if (dir == 1) inv.rotate_left(); else @@ -594,8 +602,17 @@ void player::RotateInventory(int dir) { /** Update the specified inventory on the screen, provided it is the inventory of the current player. For all other inventories, this - function does nothing. */ + function does nothing locally -- but the per-player model list is + still announced via client::NotifyInventoryChanged so a remote peer + that holds the *other* player can refresh its own status bar. */ void player::RedrawInventory(Inventory *inv) { + int owner = inv->getOwner(); + if (owner >= 0) { + std::vector modelnames; + for (size_t i = 0; i < inv->size(); ++i) + modelnames.push_back(inv->get_item(i)->get_inventory_model()); + client::NotifyInventoryChanged(owner, modelnames); + } if (inv == GetInventory(CurrentPlayer())) RedrawInventory(); } diff --git a/src/player.hh b/src/player.hh index 2e32cbee1..1b25759e3 100644 --- a/src/player.hh +++ b/src/player.hh @@ -91,7 +91,8 @@ void PickupItem(Actor *a, enigma::GridPos p); bool PickupAsItem(Actor *a, GridObject *obj, std::string kind); void RotateInventory(int dir = 1); -void ActivateFirstItem(); +void ActivateFirstItem(int iplayer); +void RotateInventory(int iplayer, int dir); ItemAction ActivateItem(Item *it); void Tick(double dtime); diff --git a/src/server.cc b/src/server.cc index a9d681e8d..21b5154f2 100644 --- a/src/server.cc +++ b/src/server.cc @@ -499,6 +499,12 @@ void Msg_Command(const string &cmd) { Msg_RestartGame(); } else if (cmd == "abort") { client::Msg_Command(cmd); + } else if (cmd == "jumpback") { + Msg_JumpBack(); + } else if (cmd == "advance_strict") { + client::Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); + } else if (cmd == "advance_unsolved") { + client::Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); } // ------------------------------ cheats @@ -656,8 +662,8 @@ int GetMoveCounter() { return move_counter; } -void Msg_ActivateItem() { - player::ActivateFirstItem(); +void Msg_ActivateItem(int iplayer) { + player::ActivateFirstItem(iplayer); } } // namespace server diff --git a/src/server.hh b/src/server.hh index a38958253..94ec6fe12 100644 --- a/src/server.hh +++ b/src/server.hh @@ -209,7 +209,7 @@ void Msg_Panic(bool onoff); void Msg_MouseForce(int player, const ecl::V2 &f); -void Msg_ActivateItem(); +void Msg_ActivateItem(int iplayer); } // namespace server } // namespace enigma From 9d0d980dad298ffa039e044a1a17e5d66ef759a3 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 10:33:44 +0200 Subject: [PATCH 07/13] make suicide per-player and disable yin-yang in LAN server::Msg_Command grows an iplayer parameter (default -1 means "all"). The "suicide" command honours it: if iplayer >= 0, only that player's actors die; otherwise the legacy global behaviour applies for any internal caller that still wants it. Local F3 / Ctrl+A pass player::CurrentPlayer(); the network dispatcher passes the connection's player index. So in a LAN game the remote pressing F3 only kills the white marble, not both. player::LevelLoaded now skips AddYinYang when netgame::IsActive(), so neither peer can swap to the other player's marble in a LAN session. Single-player and same-machine 2P keep their existing behaviour. The session flag is set before Msg_LoadLevel in both host and remote so this gate fires at the right time. player::Suicide(int) is the per-player primitive; the no-arg overload that iterates all players stays for legacy callers. --- src/client.cc | 6 +++++- src/netgame.cc | 17 +++++++++++------ src/player.cc | 14 +++++++++++++- src/player.hh | 1 + src/server.cc | 7 +++++-- src/server.hh | 4 +++- 6 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/client.cc b/src/client.cc index c6c277fcc..1a6e65ed2 100644 --- a/src/client.cc +++ b/src/client.cc @@ -518,11 +518,15 @@ void Client::on_keydown(SDL_Event &e) { SDL_Keycode keysym = e.key.keysym.sym; Uint16 keymod = e.key.keysym.mod; + // For commands that act on a specific player (e.g. "suicide"), + // local invocations target the current player. The remote + // forwards the command and the host substitutes the connection's + // player index when dispatching. auto send_command = [](const std::string &cmd) { if (netgame::IsClient()) netgame::SendInputCommand(cmd); else - server::Msg_Command(cmd); + server::Msg_Command(cmd, player::CurrentPlayer()); }; if (keymod & KMOD_CTRL) { diff --git a/src/netgame.cc b/src/netgame.cc index f1245d7e1..1877ebcbf 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -210,7 +210,7 @@ void dispatch_input_from_client(ecl::Buffer &b, int player) { case CL_COMMAND: { std::string cmd; if (b >> cmd) - server::Msg_Command(cmd); + server::Msg_Command(cmd, player); break; } case CL_INHIBIT_PICKUP: { @@ -625,6 +625,10 @@ void netgame::Start() { } } + // Mark the session as active *before* loading so per-load logic + // (like AddYinYang) can suppress single-computer behaviour. + s_in_session = true; + // Host loads the level locally with the agreed seed. Re-pin the // index position too in case it slipped between snapshot and now. ind->setCurrentPosition(level_pos); @@ -635,7 +639,6 @@ void netgame::Start() { // Cursor stays visible and mouse stays ungrabbed in network mode // so the user can move between two windows on the same machine // for testing. This may change once we have a real lobby UX. - s_in_session = true; host_main_loop(peer); s_in_session = false; fprintf(stderr, "SV: host loop exited (connected=%d, abort=%d)\n", @@ -735,6 +738,12 @@ void netgame::Join(std::string hostname, int port) { } ind->setCurrentPosition(int(level_idx)); + // Mark the session as active *before* loading so per-load logic + // (like AddYinYang) can suppress single-computer behaviour. + s_in_session = true; + s_in_client_session = true; + s_client_peer = peer; + server::RandomState = Sint32(seed); Object::setNextId(int(next_id_snapshot)); server::Msg_LoadLevel(ind->getProxy(int(level_idx)), false); @@ -748,10 +757,6 @@ void netgame::Join(std::string hostname, int port) { peer->send_reliable(ack, CHANNEL_RELIABLE); } - s_in_session = true; - s_in_client_session = true; - s_client_peer = peer; - remote_main_loop(peer); fprintf(stderr, "CL: remote loop exited (connected=%d, abort=%d)\n", int(peer->is_connected()), int(client::AbortGameP())); diff --git a/src/player.cc b/src/player.cc index f748edf35..4461184c9 100644 --- a/src/player.cc +++ b/src/player.cc @@ -21,6 +21,7 @@ #include "Inventory.hh" #include "display.hh" #include "errors.hh" +#include "netgame.hh" #include "SoundEffectManager.hh" #include "client.hh" #include "server.hh" @@ -182,7 +183,10 @@ void player::AddYinYang() { } void player::LevelLoaded(bool isRestart) { - if (server::TwoPlayerGame && server::SingleComputerGame) + // The yin-yang item lets one user swap between black and white on + // a single computer. In a LAN session each peer is locked to its + // colour, so the swap mechanic doesn't apply. + if (server::TwoPlayerGame && server::SingleComputerGame && !netgame::IsActive()) AddYinYang(); RedrawInventory(); } @@ -296,6 +300,14 @@ void player::Suicide() { } } +void player::Suicide(int iplayer) { + if ((unsigned)iplayer >= players.size()) + return; + for (auto &actor : players[iplayer].actors) { + SendMessage(actor, "_suicide"); + } +} + Actor *player::ReplaceActor(unsigned iplayer, Actor *old, Actor *a) { if (iplayer >= players.size()) server::RaiseError("Invalid actor number"); diff --git a/src/player.hh b/src/player.hh index 1b25759e3..3bdc90103 100644 --- a/src/player.hh +++ b/src/player.hh @@ -76,6 +76,7 @@ Inventory *GetInventory(Actor *a); bool WieldedItemIs(Actor *a, const std::string &kind); void Suicide(); +void Suicide(int iplayer); void AddActor(unsigned iplayer, Actor *a); void AddUnassignedActor(Actor *a); // actors not assigned to a player diff --git a/src/server.cc b/src/server.cc index 21b5154f2..b6734de85 100644 --- a/src/server.cc +++ b/src/server.cc @@ -483,7 +483,7 @@ void Msg_Command_find(const string &text) { } } -void Msg_Command(const string &cmd) { +void Msg_Command(const string &cmd, int iplayer) { lev::Index *ind = lev::Index::getCurrentIndex(); lev::Proxy *curProxy = ind->getCurrent(); @@ -491,7 +491,10 @@ void Msg_Command(const string &cmd) { if (cmd == "invrotate") { player::RotateInventory(); } else if (cmd == "suicide") { - player::Suicide(); + if (iplayer >= 0) + player::Suicide(iplayer); + else + player::Suicide(); if (!AllowSuicide) Msg_RestartGame(); } else if (cmd == "restart") { diff --git a/src/server.hh b/src/server.hh index 94ec6fe12..ec941c14b 100644 --- a/src/server.hh +++ b/src/server.hh @@ -199,7 +199,9 @@ void Msg_StartGame(); void Msg_RestartGame(); -void Msg_Command(const std::string &command); +// `iplayer` is the index of the originating player when meaningful +// (currently only the "suicide" command uses it; -1 means "global"). +void Msg_Command(const std::string &command, int iplayer = -1); void Msg_Pause(bool onoff); From e1572d8db7966d707dcdafa64fca41f8428a65f5 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 12:58:06 +0200 Subject: [PATCH 08/13] make the remote a thin renderer; fix the review issues Previously both peers ran the level's Lua at load time and tried to allocate Objects in lock-step. The id alignment was fragile: any allocation difference between the two peers (cached vs cold proxy parse, prior Lua state, residual inventory contents) silently desynced every actor id by the count of the mismatch, and the host's SV_ACTOR_MOVED packets started driving the wrong sprites on the remote. Rework so only the host loads levels: * World::Resize fires NotifyResize -> SV_RESIZE. The remote responds by calling Resize(w, h) locally, which rebuilds an empty world structure and resizes the display grid. Two SV_RESIZE events per load (initial 20x13 from PrepareLevel, then the actual level size from Lua's createWorld). * World::add_actor fires NotifyActorAdded unconditionally (was gated on !preparing_level). The remote's SV_ACTOR_ADDED dispatcher pins Object::setNextId(host_id) around MakeActor + AddActor so the new actor lands on the host's id. * The existing display::SetModel / KillModel taps populate grid sprites on the remote as they fire during the host's load. * The remote no longer calls server::Msg_LoadLevel for SV_RELOAD or for SV_ADVANCE_LEVEL / SV_JUMP_BACK; client::Msg_AdvanceLevel and Msg_JumpBack skip the local load when netgame::IsClient(). * netgame::Join calls client::Stop() before remote_main_loop so m_state left at cls_abort from a prior game doesn't trip AbortGameP() on the first iteration. * Drop the Object::next_id snapshot from the handshake, drop Proxy::release() exposure, drop the per-peer Lua execution on the client side. PROTO_VERSION bumped to 3. Also fixes the issues from the post-LAN code review: * Initialize Inventory::ownerId(-1) so RedrawInventory can't read uninitialized memory before NewGame runs. * World::move_actors re-reads actorlist size at the bottom rather than reusing a pre-physics snapshot, so mid-tick yield/add doesn't read past the new end. * World::yield_actor fires NotifyActorKilled BEFORE erasing, so the dispatcher can still resolve the id. * World::add_actor sends the actor's kind / pos / vel / owner so the remote can recreate via MakeActor. * server::Msg_Pause emits SV_PAUSE; Menu::manage calls netgame::Service() in its event-pump loop so the host's menu doesn't strand the ENet connection or the pause event. * CL_COMMAND whitelisted to suicide/restart/advance_*/jumpback. * ~Peer_Enet only disconnects when m_connected; SinkGuard + SessionGuard so a throw inside the loop doesn't leak a freed sink pointer or strand the session flags. * Wire format switched to double for actor positions; the type-pun union in ecl_buffer.cc replaced with memcpy. * Wider sound tap (sound::EmitSoundEvent) and inventory tap (player::RedrawInventory keyed by Inventory owner) so the remote sees the right player's bar. Known gaps documented in PLAN.md: per-player document text scoping, score submission semantics, occasional stale turnstile arm, lobby UI (still a Start/Join/Back stub). --- lib-src/enigma-core/ecl_buffer.cc | 52 ++-- src/Inventory.cc | 2 +- src/Object.hh | 5 +- src/actors.cc | 4 +- src/client.cc | 42 ++- src/client.hh | 47 ++- src/gui/Menu.cc | 5 + src/lev/Proxy.hh | 2 +- src/netgame.cc | 473 ++++++++++++++++++++---------- src/netgame.hh | 11 + src/network.cc | 5 +- src/oxyd.cc | 4 +- src/server.cc | 11 + src/world.cc | 39 ++- src/world.hh | 7 - 15 files changed, 481 insertions(+), 228 deletions(-) diff --git a/lib-src/enigma-core/ecl_buffer.cc b/lib-src/enigma-core/ecl_buffer.cc index 9ec0f3af5..2e17d7427 100644 --- a/lib-src/enigma-core/ecl_buffer.cc +++ b/lib-src/enigma-core/ecl_buffer.cc @@ -16,6 +16,7 @@ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "ecl_buffer.hh" +#include #include using namespace ecl; @@ -225,56 +226,37 @@ Buffer& ecl::write(Buffer& buf, Uint64 lvar) { */ /* NOTE: we reinterpret floats and doubles as Uint32/Uint64, -** and byte-swap them like integers. -** This is how quake does it, too (except they only use floats). -** I couldn't believe that this worked, so I checked it out -** (PPC <-> x86), and it does. The upside (over multiplying -** with 2048 and converting to int, as it was before) is that -** we have the same rep on both machines, possibly leading to -** less desynchronisation in network games. +** and byte-swap them like integers, so we have the same wire rep +** on both machines. memcpy is the idiomatic spelling — the optimizer +** turns it into a register move — and avoids the type-pun-via-union +** UB. */ Buffer& ecl::read(Buffer& buf, float& dvar) { - union { - float f; - Uint32 a; - } t; - if (buf >> t.a) - dvar = t.f; + Uint32 a; + if (buf >> a) + std::memcpy(&dvar, &a, sizeof dvar); return buf; } Buffer& ecl::write(Buffer& buf, float dvar) { - union { - float f; - Uint32 a; - } t; - t.f = dvar; - write(buf, t.a); + Uint32 a; + std::memcpy(&a, &dvar, sizeof dvar); + write(buf, a); return buf; } -/* - ** === Read and write doubles === - */ - Buffer& ecl::read(Buffer& buf, double& dvar) { - union { - double d; - Uint64 a; - } t; - if (buf >> t.a) - dvar = t.d; + Uint64 a; + if (buf >> a) + std::memcpy(&dvar, &a, sizeof dvar); return buf; } Buffer& ecl::write(Buffer& buf, double dvar) { - union { - double d; - Uint64 a; - } t; - t.d = dvar; - write(buf, t.a); + Uint64 a; + std::memcpy(&a, &dvar, sizeof dvar); + write(buf, a); return buf; } diff --git a/src/Inventory.cc b/src/Inventory.cc index 835286c86..2ab0eef26 100644 --- a/src/Inventory.cc +++ b/src/Inventory.cc @@ -36,7 +36,7 @@ typedef std::vector ItemList; unsigned const Inventory::max_items = 12; -Inventory::Inventory() : m_items () { +Inventory::Inventory() : m_items(), ownerId(-1) { } diff --git a/src/Object.hh b/src/Object.hh index 104f44d89..44cfce56e 100644 --- a/src/Object.hh +++ b/src/Object.hh @@ -94,8 +94,9 @@ public: static Object *getObject(int id); int getId() const; - // Snapshot/restore the global id counter. Used to keep two ENet - // peers' object ids in lock-step across a network level load. + // Snapshot/restore the global id counter. Used by the LAN + // protocol's SV_ACTOR_ADDED dispatcher to land each new actor on + // the host's id, so subsequent SV_ACTOR_MOVED packets resolve. static int getNextIdSnapshot(); static void setNextId(int v); diff --git a/src/actors.cc b/src/actors.cc index 384b665e7..a5e3c08d3 100644 --- a/src/actors.cc +++ b/src/actors.cc @@ -313,9 +313,7 @@ void Actor::move_screen() { void Actor::set_model(const std::string &name) { m_sprite.replace_model(display::MakeModel(name)); - int idx = FindActorIndex(this); - if (idx >= 0) - client::NotifyActorSpriteChanged(idx, name); + client::NotifyActorSpriteChanged(getId(), name); } void Actor::animcb() { diff --git a/src/client.cc b/src/client.cc index 1a6e65ed2..64af0a556 100644 --- a/src/client.cc +++ b/src/client.cc @@ -118,12 +118,23 @@ void UnregisterEventSink(EventSink *sink) { event_sinks.erase(it); } -void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) { - for (auto *s : event_sinks) s->OnActorMoved(actor_id, pos, vel); +void NotifyActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) { + for (auto *s : event_sinks) s->OnActorMoved(object_id, pos, vel); } -void NotifyActorSpriteChanged(int actor_id, const std::string &model_name) { - for (auto *s : event_sinks) s->OnActorSpriteChanged(actor_id, model_name); +void NotifyActorSpriteChanged(int object_id, const std::string &model_name) { + for (auto *s : event_sinks) s->OnActorSpriteChanged(object_id, model_name); +} + +void NotifyActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) { + for (auto *s : event_sinks) + s->OnActorAdded(object_id, kind, pos, vel, owner_player); +} + +void NotifyActorKilled(int object_id) { + for (auto *s : event_sinks) s->OnActorKilled(object_id); } void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name) { @@ -153,6 +164,18 @@ void NotifyMoveCounter(int value) { s->OnMoveCounter(value); } +void NotifyPause(bool onoff) { + for (auto *s : event_sinks) s->OnPause(onoff); +} + +void NotifyReload(int level_idx) { + for (auto *s : event_sinks) s->OnReload(level_idx); +} + +void NotifyResize(int w, int h) { + for (auto *s : event_sinks) s->OnResize(w, h); +} + /* -------------------- Client class -------------------- */ Client::Client() @@ -1080,8 +1103,10 @@ void Msg_AdvanceLevel(lev::LevelAdvanceMode mode) { lev::PersistentIndex::addCurrentToHistory(); if (level_index->advanceLevel(mode)) { - // now we may advance - server::Msg_LoadLevel(level_index->getCurrent(), false); + // The remote doesn't run levels itself — the host streams the + // new world state. We just advance the index for the caption. + if (!netgame::IsClient()) + server::Msg_LoadLevel(level_index->getCurrent(), false); } else client::Msg_Command("abort"); } @@ -1090,7 +1115,10 @@ void Msg_JumpBack() { for (auto *s : event_sinks) s->OnJumpBack(); // log last played level lev::PersistentIndex::addCurrentToHistory(); - server::Msg_JumpBack(); + // On the remote, the host runs the actual jumpback and streams + // the new world state; we skip the local load. + if (!netgame::IsClient()) + server::Msg_JumpBack(); } bool AbortGameP() { diff --git a/src/client.hh b/src/client.hh index 92b0f7227..a06f41d20 100644 --- a/src/client.hh +++ b/src/client.hh @@ -21,6 +21,7 @@ #include "ecl_math.hh" #include "lev/Index.hh" +#include #include #include @@ -81,12 +82,43 @@ public: // The status bar's stone-move counter (sokoban-style score). virtual void OnMoveCounter(int value) {} - virtual void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) {} - virtual void OnActorSpriteChanged(int actor_id, const std::string &model_name) {} + // Per-tick state, keyed by the actor's Object::getId(). Same id on + // both peers because the handshake aligns Object::next_id and the + // level load is deterministic from the shared seed. + virtual void OnActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) {} + virtual void OnActorSpriteChanged(int object_id, const std::string &model_name) {} + + // Actor lifecycle: fired when an actor is added to or yielded from + // the live actor list mid-game (Drop.cc rotor swap, cannons, Lua + // spawns, etc.). The remote mirrors the call so the actor exists + // on its side too, with a matching Object id. + virtual void OnActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) {} + virtual void OnActorKilled(int object_id) {} + // layer is GRID_FLOOR / GRID_ITEMS / GRID_STONES. virtual void OnGridSpriteChanged(int layer, int x, int y, const std::string &model_name) {} virtual void OnGridSpriteCleared(int layer, int x, int y) {} + + // Server-side pause state. The host fires this when its menu / + // help screen opens or closes; the remote uses it to display a + // "paused by host" overlay. + virtual void OnPause(bool onoff) {} + + // Host has loaded a level. Used for the in-session level + // transitions (restart, advance) — the remote uses this as the + // signal to drop its current world state before applying the + // grid/actor events that follow. + virtual void OnReload(int level_idx) {} + + // Host's world was resized. Fires from World::Resize, twice per + // load (initial 20x13 from PrepareLevel, then the actual size + // from the level's createWorld). The remote calls Resize(w, h) + // locally; the empty world is then populated by subsequent + // SV_GRID_SPRITE and SV_ACTOR_ADDED events. + virtual void OnResize(int w, int h) {} }; void RegisterEventSink(EventSink *sink); @@ -94,8 +126,12 @@ void UnregisterEventSink(EventSink *sink); // Notify hooks for engine mutation points that don't pass through // a Msg_* function. No local side effect; purely a tap for sinks. -void NotifyActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel); -void NotifyActorSpriteChanged(int actor_id, const std::string &model_name); +void NotifyActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel); +void NotifyActorSpriteChanged(int object_id, const std::string &model_name); +void NotifyActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player); +void NotifyActorKilled(int object_id); void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name); void NotifyGridSpriteCleared(int layer, int x, int y); void NotifySound(const std::string &soundname, const ecl::V2 &pos, @@ -103,6 +139,9 @@ void NotifySound(const std::string &soundname, const ecl::V2 &pos, void NotifyInventoryChanged(int player_index, const std::vector &model_names); void NotifyMoveCounter(int value); +void NotifyPause(bool onoff); +void NotifyReload(int level_idx); +void NotifyResize(int w, int h); /* -------------------- Server->Client messages -------------------- */ diff --git a/src/gui/Menu.cc b/src/gui/Menu.cc index 874ff7f91..ba654ac23 100644 --- a/src/gui/Menu.cc +++ b/src/gui/Menu.cc @@ -20,6 +20,7 @@ #include "gui/Menu.hh" #include "SoundEffectManager.hh" #include "MusicManager.hh" +#include "netgame.hh" #include "video.hh" #include "options.hh" #include "main.hh" @@ -101,6 +102,10 @@ Menu::Menu() if(key_focus_widget && (key_focus_widget != active_widget)) key_focus_widget->tick(0.01); tick(0.01); sound::MusicTick(0.01); + // Keep the LAN session alive while this modal is open and + // forward the host's SV_PAUSE state to the remote. No-op + // when no session is active. + netgame::Service(); refresh(); } sound::EmitSoundEvent ("menuexit"); diff --git a/src/lev/Proxy.hh b/src/lev/Proxy.hh index 8fda50dce..8a43345c7 100644 --- a/src/lev/Proxy.hh +++ b/src/lev/Proxy.hh @@ -156,7 +156,7 @@ namespace enigma { namespace lev { protected: std::string title; - + Proxy(bool proxyIsLibrary, pathType thePathType, std::string theNormLevelPath, std::string levelId, std::string levelTitle, std::string levelAuthor, int levelScoreVersion, int levelRelease, bool levelHasEasymode, diff --git a/src/netgame.cc b/src/netgame.cc index 1877ebcbf..a68a6b480 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -24,6 +24,7 @@ #include "main.hh" #include "netgame.hh" #include "network.hh" +#include "Object.hh" #include "options.hh" #include "player.hh" #include "server.hh" @@ -39,6 +40,7 @@ #include "SDL.h" #include +#include #include using namespace enigma; @@ -51,7 +53,7 @@ using namespace enigma; namespace { -constexpr Uint16 PROTO_VERSION = 1; +constexpr Uint16 PROTO_VERSION = 3; constexpr int NET_PORT = 12345; // Packet tags. Client -> host inputs live in the 0x10–0x7F band, host @@ -86,11 +88,23 @@ enum ProtoTag : Uint8 { SV_GRID_KILL = 0x90, SV_INVENTORY = 0x91, SV_MOVE_COUNTER = 0x92, + SV_ACTOR_ADDED = 0x93, + SV_ACTOR_KILLED = 0x94, + SV_PAUSE = 0x95, + SV_RELOAD = 0x96, + SV_RESIZE = 0x97, }; constexpr int CHANNEL_UNRELIABLE = 0; constexpr int CHANNEL_RELIABLE = 1; +// Commands the remote is allowed to forward to the host. Anything else +// is dropped — LAN trust model, but the door doesn't need to be open +// to cheats and arbitrary jumpto/find traffic. +const std::set kAllowedClientCommands = { + "suicide", "restart", "advance_strict", "advance_unsolved", "jumpback", +}; + // Batches outbound EventSink calls into a reliable and an unreliable // buffer; flush() ships both. Position-style updates that arrive every // tick go unreliable; everything else is reliable. @@ -112,10 +126,10 @@ class NetworkSink : public client::EventSink { } void OnPlayerPosition(unsigned iplayer, const ecl::V2 &pos) override { m_unreliable << Uint8(SV_PLAYER_POSITION) << Uint8(iplayer) - << float(pos[0]) << float(pos[1]); + << double(pos[0]) << double(pos[1]); } void OnSparkle(const ecl::V2 &pos) override { - m_reliable << Uint8(SV_SPARKLE) << float(pos[0]) << float(pos[1]); + m_reliable << Uint8(SV_SPARKLE) << double(pos[0]) << double(pos[1]); } void OnShowText(const std::string &text, bool scrolling, double duration) override { m_reliable << Uint8(SV_SHOW_TEXT) << text @@ -137,7 +151,7 @@ class NetworkSink : public client::EventSink { void OnSound(const std::string &soundname, const ecl::V2 &pos, double volume, bool global) override { m_reliable << Uint8(SV_SOUND) << soundname - << float(pos[0]) << float(pos[1]) + << double(pos[0]) << double(pos[1]) << double(volume) << Uint8(global ? 1 : 0); } void OnInventoryChanged(int player_index, @@ -150,13 +164,25 @@ class NetworkSink : public client::EventSink { void OnMoveCounter(int value) override { m_reliable << Uint8(SV_MOVE_COUNTER) << Uint32(value); } - void OnActorMoved(int actor_id, const ecl::V2 &pos, const ecl::V2 &vel) override { - m_unreliable << Uint8(SV_ACTOR_MOVED) << Uint32(actor_id) - << float(pos[0]) << float(pos[1]) - << float(vel[0]) << float(vel[1]); + void OnActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) override { + m_unreliable << Uint8(SV_ACTOR_MOVED) << Uint32(object_id) + << double(pos[0]) << double(pos[1]) + << double(vel[0]) << double(vel[1]); + } + void OnActorSpriteChanged(int object_id, const std::string &model_name) override { + m_reliable << Uint8(SV_ACTOR_SPRITE) << Uint32(object_id) << model_name; } - void OnActorSpriteChanged(int actor_id, const std::string &model_name) override { - m_reliable << Uint8(SV_ACTOR_SPRITE) << Uint32(actor_id) << model_name; + void OnActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) override { + // owner: -1 → 0xFF, else 0..1. Decoded via Sint8 round-trip. + m_reliable << Uint8(SV_ACTOR_ADDED) << Uint32(object_id) << kind + << double(pos[0]) << double(pos[1]) + << double(vel[0]) << double(vel[1]) + << Uint8(Sint8(owner_player)); + } + void OnActorKilled(int object_id) override { + m_reliable << Uint8(SV_ACTOR_KILLED) << Uint32(object_id); } void OnGridSpriteChanged(int layer, int x, int y, const std::string &model_name) override { @@ -167,6 +193,15 @@ class NetworkSink : public client::EventSink { m_reliable << Uint8(SV_GRID_KILL) << Uint8(layer) << Uint16(x) << Uint16(y); } + void OnPause(bool onoff) override { + m_reliable << Uint8(SV_PAUSE) << Uint8(onoff ? 1 : 0); + } + void OnReload(int level_idx) override { + m_reliable << Uint8(SV_RELOAD) << Uint32(level_idx); + } + void OnResize(int w, int h) override { + m_reliable << Uint8(SV_RESIZE) << Uint16(w) << Uint16(h); + } void flush() { if (m_reliable.size() > 0) { @@ -185,6 +220,12 @@ class NetworkSink : public client::EventSink { ecl::Buffer m_unreliable; }; +// Look up an Actor by its host-side Object id. Returns nullptr if no +// such object exists locally or the id resolves to a non-actor. +Actor *find_actor_by_id(Uint32 id) { + return dynamic_cast(Object::getObject(int(id))); +} + // Parse a packet that arrived on the host. `player` is the index // assigned to that connection (0 or 1); inputs are tagged with it on // the server side so the simulation knows whose marble to drive. @@ -193,7 +234,7 @@ void dispatch_input_from_client(ecl::Buffer &b, int player) { while (b >> tag) { switch (tag) { case CL_MOUSE_FORCE: { - float dx, dy; + double dx, dy; if (b >> dx >> dy) server::Msg_MouseForce(player, ecl::V2(dx, dy)); break; @@ -209,8 +250,12 @@ void dispatch_input_from_client(ecl::Buffer &b, int player) { } case CL_COMMAND: { std::string cmd; - if (b >> cmd) - server::Msg_Command(cmd, player); + if (b >> cmd) { + if (kAllowedClientCommands.count(cmd)) + server::Msg_Command(cmd, player); + else + enigma::Log << "netgame: rejected client command '" << cmd << "'\n"; + } break; } case CL_INHIBIT_PICKUP: { @@ -230,6 +275,121 @@ void dispatch_input_from_client(ecl::Buffer &b, int player) { // local state. Goes through the existing client::Msg_* and engine // APIs; since no NetworkSink is registered on the remote, this does // not re-broadcast. +void dispatch_event_from_server(ecl::Buffer &b); + +} // anonymous namespace + +//====================================================================== +// Client-session state and input helpers +//====================================================================== + +namespace { + +bool s_in_session = false; // host or client +bool s_in_client_session = false; // client only +bool s_paused_by_host = false; // remote: host has its menu open +Peer *s_client_peer = nullptr; +ecl::Buffer s_client_out_unreliable; +ecl::Buffer s_client_out_reliable; + +// Per-connection state on the host. Set by Start() before its main +// loop and read both by the dispatcher (to tag inputs with the +// correct player index) and by Service() (to flush its sink). +struct HostState { + Peer *peer = nullptr; + NetworkSink *sink = nullptr; + int remote_player = 1; +}; +HostState s_host; + +void flush_client_outbox() { + if (s_client_out_unreliable.size() > 0 && s_client_peer) { + s_client_peer->send_message(s_client_out_unreliable, CHANNEL_UNRELIABLE); + s_client_out_unreliable.clear(); + } + if (s_client_out_reliable.size() > 0 && s_client_peer) { + s_client_peer->send_reliable(s_client_out_reliable, CHANNEL_RELIABLE); + s_client_out_reliable.clear(); + } +} + +// Apply the host's level-reload event on the remote. We do NOT run +// the level's Lua here — the host runs the simulation and streams +// the resulting world state via SV_RESIZE + SV_GRID_SPRITE + +// SV_ACTOR_ADDED events. We make sure a (default-sized) world +// exists locally so subsequent code that walks it (Glasses, +// SetCurrentPlayer, etc.) doesn't dereference a null `level`. The +// real size arrives in the host's SV_RESIZE. +void apply_reload(int level_idx) { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind && level_idx >= 0 && level_idx < ind->size()) + ind->setCurrentPosition(level_idx); + Resize(20, 13); +} + +} // namespace + +bool netgame::IsClient() { + return s_in_client_session; +} + +bool netgame::IsActive() { + return s_in_session; +} + +void netgame::SendInputMouseForce(const ecl::V2 &f) { + if (!s_in_client_session) return; + s_client_out_unreliable << Uint8(CL_MOUSE_FORCE) << double(f[0]) << double(f[1]); +} + +void netgame::SendInputActivateItem() { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ACTIVATE_ITEM); +} + +void netgame::SendInputRotateInventory(int dir) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ROTATE_INVENTORY) << Uint8(Sint8(dir)); +} + +void netgame::SendInputCommand(const std::string &cmd) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_COMMAND) << cmd; +} + +void netgame::SendInputInhibitPickup(bool onoff) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_INHIBIT_PICKUP) << Uint8(onoff ? 1 : 0); +} + +// Service the network connection once: drain inbound packets and flush +// outbound. Called from inside modal GUI loops (game menu, help) so a +// host whose menu is open keeps the ENet connection alive and forwards +// pause state. Called by both host and remote. +void netgame::Service() { + if (!s_in_session) return; + if (s_in_client_session) { + if (s_client_peer && s_client_peer->is_connected()) { + ecl::Buffer buf; + int dummy; + while (s_client_peer->poll_message(buf, dummy)) + dispatch_event_from_server(buf); + flush_client_outbox(); + } + } else { + if (s_host.peer && s_host.peer->is_connected()) { + ecl::Buffer buf; + int dummy; + while (s_host.peer->poll_message(buf, dummy)) + dispatch_input_from_client(buf, s_host.remote_player); + if (s_host.sink) + s_host.sink->flush(); + } + } +} + +namespace { + void dispatch_event_from_server(ecl::Buffer &b) { Uint8 tag; while (b >> tag) { @@ -253,17 +413,17 @@ void dispatch_event_from_server(ecl::Buffer &b) { break; } case SV_PLAYER_POSITION: { - Uint8 ip; float x, y; + Uint8 ip; double x, y; if (b >> ip >> x >> y) client::Msg_PlayerPosition(ip, ecl::V2(x, y)); break; } case SV_SPARKLE: { - float x, y; + double x, y; if (b >> x >> y) client::Msg_Sparkle(ecl::V2(x, y)); break; } case SV_SOUND: { - std::string sn; float x, y; double v; Uint8 global; + std::string sn; double x, y, v; Uint8 global; if (b >> sn >> x >> y >> v >> global) sound::EmitSoundEvent(sn, ecl::V2(x, y), v, global != 0); break; @@ -317,9 +477,9 @@ void dispatch_event_from_server(ecl::Buffer &b) { break; } case SV_ACTOR_MOVED: { - Uint32 idx; float px, py, vx, vy; - if (b >> idx >> px >> py >> vx >> vy) { - if (Actor *a = GetActorByIndex(int(idx))) { + Uint32 id; double px, py, vx, vy; + if (b >> id >> px >> py >> vx >> vy) { + if (Actor *a = find_actor_by_id(id)) { ActorInfo *ai = a->get_actorinfo(); ai->pos = ecl::V2(px, py); ai->vel = ecl::V2(vx, vy); @@ -327,20 +487,51 @@ void dispatch_event_from_server(ecl::Buffer &b) { } else { static int miss_count = 0; if (++miss_count <= 5) - fprintf(stderr, "CL: SV_ACTOR_MOVED for unknown index %u\n", - unsigned(idx)); + fprintf(stderr, "CL: SV_ACTOR_MOVED for unknown id %u\n", + unsigned(id)); } } break; } case SV_ACTOR_SPRITE: { - Uint32 idx; std::string model; - if (b >> idx >> model) { - if (Actor *a = GetActorByIndex(int(idx))) + Uint32 id; std::string model; + if (b >> id >> model) { + if (Actor *a = find_actor_by_id(id)) a->set_model(model); } break; } + case SV_ACTOR_ADDED: { + Uint32 id; std::string kind; + double px, py, vx, vy; Uint8 owner_byte; + if (b >> id >> kind >> px >> py >> vx >> vy >> owner_byte) { + int owner = int(Sint8(owner_byte)); + // Force the new Object to land on the host's id by + // pinning next_id, creating, then restoring if the + // remote was already past that point. + int saved = Object::getNextIdSnapshot(); + Object::setNextId(int(id)); + Actor *a = MakeActor(kind.c_str()); + if (a) { + if (owner >= 0) + a->setAttr("owner", Value(int(owner))); + AddActor(px, py, a); + ActorInfo *ai = a->get_actorinfo(); + ai->vel = ecl::V2(vx, vy); + } + if (saved > int(id) + 1) + Object::setNextId(saved); + } + break; + } + case SV_ACTOR_KILLED: { + Uint32 id; + if (b >> id) { + if (Actor *a = find_actor_by_id(id)) + KillActor(a); + } + break; + } case SV_GRID_SPRITE: { Uint8 layer; Uint16 x, y; std::string model; if (b >> layer >> x >> y >> model) @@ -354,6 +545,28 @@ void dispatch_event_from_server(ecl::Buffer &b) { display::KillModel(GridLoc(GridLayer(layer), GridPos(int(x), int(y)))); break; } + case SV_PAUSE: { + Uint8 on; + if (b >> on) { + s_paused_by_host = (on != 0); + client::Msg_ShowText( + s_paused_by_host ? "Paused by host..." : "Resumed.", + false, s_paused_by_host ? 1e9 : 1.0); + } + break; + } + case SV_RELOAD: { + Uint32 idx; + if (b >> idx) + apply_reload(int(idx)); + break; + } + case SV_RESIZE: { + Uint16 w, h; + if (b >> w >> h) + Resize(int(w), int(h)); + break; + } default: enigma::Log << "netgame: unknown SV tag 0x" << std::hex << int(tag) << "\n"; return; @@ -364,90 +577,52 @@ void dispatch_event_from_server(ecl::Buffer &b) { } // anonymous namespace //====================================================================== -// Client-session state and input helpers +// Handshake and main loops //====================================================================== namespace { -bool s_in_session = false; // host or client -bool s_in_client_session = false; // client only -Peer *s_client_peer = nullptr; -ecl::Buffer s_client_out_unreliable; -ecl::Buffer s_client_out_reliable; - -void flush_client_outbox() { - if (s_client_out_unreliable.size() > 0 && s_client_peer) { - s_client_peer->send_message(s_client_out_unreliable, CHANNEL_UNRELIABLE); - s_client_out_unreliable.clear(); - } - if (s_client_out_reliable.size() > 0 && s_client_peer) { - s_client_peer->send_reliable(s_client_out_reliable, CHANNEL_RELIABLE); - s_client_out_reliable.clear(); +// RAII guard: clears the session flags on scope exit so any thrown +// exception leaves the netgame state coherent. +struct SessionGuard { + bool clear_client; + SessionGuard(bool client) : clear_client(client) {} + ~SessionGuard() { + if (clear_client) { + flush_client_outbox(); + s_in_client_session = false; + s_client_peer = nullptr; + s_paused_by_host = false; + } else { + s_host = HostState{}; + } + s_in_session = false; } -} - -} // namespace - -bool netgame::IsClient() { - return s_in_client_session; -} - -bool netgame::IsActive() { - return s_in_session; -} - -void netgame::SendInputMouseForce(const ecl::V2 &f) { - if (!s_in_client_session) return; - s_client_out_unreliable << Uint8(CL_MOUSE_FORCE) << float(f[0]) << float(f[1]); -} - -void netgame::SendInputActivateItem() { - if (!s_in_client_session) return; - s_client_out_reliable << Uint8(CL_ACTIVATE_ITEM); -} - -void netgame::SendInputRotateInventory(int dir) { - if (!s_in_client_session) return; - s_client_out_reliable << Uint8(CL_ROTATE_INVENTORY) << Uint8(Sint8(dir)); -} - -void netgame::SendInputCommand(const std::string &cmd) { - if (!s_in_client_session) return; - s_client_out_reliable << Uint8(CL_COMMAND) << cmd; -} - -void netgame::SendInputInhibitPickup(bool onoff) { - if (!s_in_client_session) return; - s_client_out_reliable << Uint8(CL_INHIBIT_PICKUP) << Uint8(onoff ? 1 : 0); -} - -//====================================================================== -// Handshake and main loops -//====================================================================== - -namespace { +}; -void log_player_actor_ids(const char *who) { - for (unsigned p = 0; p < 2; ++p) { - Actor *a = player::GetMainActor(p); - if (a) - fprintf(stderr, "%s: player %u main actor id=%d index=%d\n", - who, p, a->getId(), FindActorIndex(a)); - else - fprintf(stderr, "%s: player %u has no main actor\n", who, p); +// Pair an EventSink's lifetime with the registry. Ensures a thrown +// exception inside the loop doesn't leave a freed sink pointer in +// `event_sinks`. +struct SinkGuard { + client::EventSink *sink; + explicit SinkGuard(client::EventSink *s) : sink(s) { + client::RegisterEventSink(sink); } -} + ~SinkGuard() { client::UnregisterEventSink(sink); } +}; -// Wait up to `timeout_ms` for a single packet to arrive. Returns true -// on success (with the packet's bytes in `out`), false on timeout or -// disconnect. +// Wait up to `timeout_ms` for a single packet to arrive. Uses +// enet_host_service's own timeout so we don't busy-poll. Returns +// true with the packet's bytes in `out` on success, false on +// timeout or disconnect. bool wait_for_packet(Peer *peer, ecl::Buffer &out, int timeout_ms) { Uint32 start = SDL_GetTicks(); while (peer->is_connected()) { int dummy; if (peer->poll_message(out, dummy)) return true; - if ((Uint32)(SDL_GetTicks() - start) >= (Uint32)timeout_ms) + Uint32 elapsed = SDL_GetTicks() - start; + if (elapsed >= (Uint32)timeout_ms) return false; SDL_Delay(5); } @@ -457,9 +632,6 @@ bool wait_for_packet(Peer *peer, ecl::Buffer &out, int timeout_ms) { // Run the host's main loop: full simulation, plus broadcast every // EventSink hit and consume the remote's inputs each tick. void host_main_loop(Peer *peer) { - NetworkSink sink(peer); - client::RegisterEventSink(&sink); - Uint32 last_tick_time = SDL_GetTicks(); double dtime = 0; while (!client::AbortGameP() && peer->is_connected() && !app.bossKeyPressed) { @@ -473,14 +645,9 @@ void host_main_loop(Peer *peer) { server::Msg_Panic(true); } - ecl::Buffer buf; - int dummy; - while (peer->poll_message(buf, dummy)) - dispatch_input_from_client(buf, /*player=*/1); - - sink.flush(); + netgame::Service(); - int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); + int sleeptime = 10 - int(SDL_GetTicks() - last_tick_time); if (sleeptime >= 3) SDL_Delay(sleeptime); Uint32 now = SDL_GetTicks(); @@ -490,8 +657,6 @@ void host_main_loop(Peer *peer) { if (dtime > 500.0) dtime = 0.0; } - - client::UnregisterEventSink(&sink); } // Run the remote's main loop: no physics, just consume state from the @@ -510,14 +675,9 @@ void remote_main_loop(Peer *peer) { break; } - ecl::Buffer buf; - int dummy; - while (peer->poll_message(buf, dummy)) - dispatch_event_from_server(buf); - - flush_client_outbox(); + netgame::Service(); - int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); + int sleeptime = 10 - int(SDL_GetTicks() - last_tick_time); if (sleeptime >= 3) SDL_Delay(sleeptime); Uint32 now = SDL_GetTicks(); @@ -540,9 +700,6 @@ void netgame::Start() { fprintf(stderr, "SV: no current level pack selected.\n"); return; } - // Snapshot position once so the proxy we load and the level number - // we ship to the remote stay consistent even if something else - // would otherwise advance the index in between. int level_pos = ind->getCurrentPosition(); lev::Proxy *proxy = ind->getProxy(level_pos); if (proxy == nullptr) { @@ -588,11 +745,19 @@ void netgame::Start() { SDL_Delay(10); } - // Handshake. Pick a fresh RNG seed and ship the level coordinates, - // plus the host's current Object::next_id snapshot so the remote - // can sync its id allocator and end up with matching actor ids. Uint32 seed = Uint32(SDL_GetTicks()) ^ Uint32(uintptr_t(peer)); - Uint32 next_id_snapshot = Uint32(Object::getNextIdSnapshot()); + const int remote_player = 1; + + // Mark the session as active *before* loading so per-load logic + // (like AddYinYang) can suppress single-computer behaviour. + s_in_session = true; + SessionGuard guard(/*client=*/false); + + NetworkSink sink(peer); + s_host.peer = peer; + s_host.sink = &sink; + s_host.remote_player = remote_player; + { ecl::Buffer hello; hello << Uint8(SV_HELLO) @@ -600,8 +765,7 @@ void netgame::Start() { << ind->getName() << Uint32(level_pos) << seed - << next_id_snapshot - << Uint8(1); // remote is player 1 + << Uint8(remote_player); peer->send_reliable(hello, CHANNEL_RELIABLE); } ecl::Buffer reply; @@ -625,22 +789,26 @@ void netgame::Start() { } } - // Mark the session as active *before* loading so per-load logic - // (like AddYinYang) can suppress single-computer behaviour. - s_in_session = true; + // Register the sink BEFORE the initial load so every event fired + // during load — SV_RESIZE, SV_GRID_SPRITE, SV_ACTOR_ADDED, + // SV_INVENTORY, SV_LEVEL_LOADED — reaches the remote. The remote + // builds its world entirely from this event stream. + SinkGuard sink_guard(&sink); - // Host loads the level locally with the agreed seed. Re-pin the - // index position too in case it slipped between snapshot and now. ind->setCurrentPosition(level_pos); server::RandomState = Sint32(seed); server::Msg_LoadLevel(proxy, false); - log_player_actor_ids("SV"); - // Cursor stays visible and mouse stays ungrabbed in network mode - // so the user can move between two windows on the same machine - // for testing. This may change once we have a real lobby UX. + // Grab the mouse and hide the cursor like single-player. Pass + // --nograb to keep both visible when debugging two peers on the + // same machine. + video_engine->HideMouse(); + ScopedInputGrab grab(not enigma::Nograb); + host_main_loop(peer); - s_in_session = false; + + video_engine->ShowMouse(); + fprintf(stderr, "SV: host loop exited (connected=%d, abort=%d)\n", int(peer->is_connected()), int(client::AbortGameP())); @@ -704,22 +872,24 @@ void netgame::Join(std::string hostname, int port) { std::string level_pack; Uint32 level_idx = 0; Uint32 seed = 0; - Uint32 next_id_snapshot = 0; Uint8 assigned_player = 1; - hello >> tag >> proto >> level_pack >> level_idx >> seed >> next_id_snapshot - >> assigned_player; + hello >> tag >> proto >> level_pack >> level_idx >> seed >> assigned_player; if (tag != SV_HELLO || proto != PROTO_VERSION) { - fprintf(stderr, "CL: bad SV_HELLO (tag=0x%x proto=%d).\n", tag, int(proto)); + fprintf(stderr, "CL: bad SV_HELLO (tag=0x%x proto=%d, expected %d).\n", + tag, int(proto), int(PROTO_VERSION)); peer->disconnect(); delete peer; enet_host_destroy(network_host); return; } - printf("CL: host wants level pack '%s' level %d, seed=0x%x, next_id=%u, " + printf("CL: host wants level pack '%s' level %d, seed=0x%x, " "assigned player=%d\n", level_pack.c_str(), int(level_idx) + 1, unsigned(seed), - unsigned(next_id_snapshot), int(assigned_player)); + int(assigned_player)); + // The remote needs the level pack present only to read level + // titles for the window caption — it does not load the level + // itself, the host streams the populated world. if (!lev::Index::setCurrentIndex(level_pack)) { fprintf(stderr, "CL: missing level pack '%s'.\n", level_pack.c_str()); peer->disconnect(); @@ -736,19 +906,19 @@ void netgame::Join(std::string hostname, int port) { enet_host_destroy(network_host); return; } - ind->setCurrentPosition(int(level_idx)); - // Mark the session as active *before* loading so per-load logic - // (like AddYinYang) can suppress single-computer behaviour. s_in_session = true; s_in_client_session = true; s_client_peer = peer; + SessionGuard guard(/*client=*/true); server::RandomState = Sint32(seed); - Object::setNextId(int(next_id_snapshot)); - server::Msg_LoadLevel(ind->getProxy(int(level_idx)), false); + // Reset client state from any prior game (cls_abort etc.) so the + // remote_main_loop's AbortGameP() check doesn't trip on startup. + // SV_LEVEL_LOADED will move us to cls_preparing_game shortly. + client::Stop(); + apply_reload(int(level_idx)); player::SetCurrentPlayer(assigned_player); - log_player_actor_ids("CL"); // Acknowledge. { @@ -757,16 +927,23 @@ void netgame::Join(std::string hostname, int port) { peer->send_reliable(ack, CHANNEL_RELIABLE); } + // Grab the mouse and hide the cursor like single-player. Pass + // --nograb to keep both visible when debugging two peers on the + // same machine. + video_engine->HideMouse(); + ScopedInputGrab grab(not enigma::Nograb); + remote_main_loop(peer); fprintf(stderr, "CL: remote loop exited (connected=%d, abort=%d)\n", int(peer->is_connected()), int(client::AbortGameP())); - flush_client_outbox(); - s_in_client_session = false; - s_in_session = false; - s_client_peer = nullptr; + video_engine->ShowMouse(); peer->disconnect(); delete peer; enet_host_destroy(network_host); } + +bool netgame::IsPausedByHost() { + return s_paused_by_host; +} diff --git a/src/netgame.hh b/src/netgame.hh index 804acf1d1..86f42f937 100644 --- a/src/netgame.hh +++ b/src/netgame.hh @@ -37,6 +37,17 @@ bool IsClient(); // auto-pause on focus loss, hide cursor) during LAN play. bool IsActive(); +// True on the remote when the host has opened its in-game menu / help +// (i.e. the simulation is paused on the host's side). +bool IsPausedByHost(); + +// Drain the inbound packet queue and flush the outbound batches once. +// Safe to call when no session is active (no-op). The two main loops +// already call this each tick; modal GUI loops (game menu, help) also +// call it periodically so a long-open menu doesn't time out the ENet +// connection and so SV_PAUSE state reaches the remote promptly. +void Service(); + // Send-input helpers used by the local UI layer to forward the player's // inputs to the host. No-ops outside a client session. void SendInputMouseForce(const ecl::V2 &f); diff --git a/src/network.cc b/src/network.cc index 953d916c2..6e90eb459 100644 --- a/src/network.cc +++ b/src/network.cc @@ -45,9 +45,10 @@ Peer_Enet::Peer_Enet (ENetHost *host, ENetPeer *peer, int playerno) } -Peer_Enet::~Peer_Enet() +Peer_Enet::~Peer_Enet() { - disconnect(0); + if (m_connected) + disconnect(0); } diff --git a/src/oxyd.cc b/src/oxyd.cc index ff8b72b65..f4a2a40fd 100644 --- a/src/oxyd.cc +++ b/src/oxyd.cc @@ -231,7 +231,7 @@ void OxydLoader::load () { // Prepare Enigma game engine Resize (level.getWidth(), level.getHeight()); - if (config.twoplayers) + if (config.twoplayers) server::TwoPlayerGame = true; display::ResizeGameArea (20, 11); if (level.getScrolling()) @@ -239,7 +239,7 @@ void OxydLoader::load () else SetFollowMode (display::FOLLOW_SCREEN); - // Populate Enigma game + // Populate Enigma game load_floor (); load_items (); load_stones (); diff --git a/src/server.cc b/src/server.cc index b6734de85..f47c62b35 100644 --- a/src/server.cc +++ b/src/server.cc @@ -33,6 +33,7 @@ #include "StateManager.hh" #include "world.hh" #include "MusicManager.hh" +#include "netgame.hh" #include "enet/enet.h" @@ -356,6 +357,13 @@ void Tick(double dtime) { current_state_dtime += dtime; if (current_state_dtime >= 1.0) { lev::Index *ind = lev::Index::getCurrentIndex(); + // In a network session, tell the remote a reload is + // imminent so it can drop its current world. The host's + // re-run of load_level then fires SV_RESIZE + + // SV_GRID_SPRITE + SV_ACTOR_ADDED events that rebuild + // the remote's world state. + if (netgame::IsActive() && !netgame::IsClient()) + client::NotifyReload(ind->getCurrentPosition()); load_level(ind->getCurrent(), (state == sv_restart_level)); } else { gametick(dtime); @@ -590,12 +598,15 @@ void Msg_Command(const string &cmd, int iplayer) { } void Msg_Pause(bool onoff) { + ServerState before = state; if (onoff && state == sv_running) state = sv_paused; else if (onoff && state == sv_teatime) state = sv_paused; else if (!onoff && state == sv_paused) state = sv_running; + if (state != before) + client::NotifyPause(onoff); } void Msg_Teatime(bool onoff) { diff --git a/src/world.cc b/src/world.cc index 8d2b0dc73..1ba9a9465 100644 --- a/src/world.cc +++ b/src/world.cc @@ -417,6 +417,15 @@ void World::add_actor(Actor *a, const V2 &pos) { // if game is already running, call on_creation() from here a->on_creation(pos); } + // Tell the remote about every actor (load-time and mid-game), so + // it can mirror them on its side with a matching Object id. The + // remote doesn't run the level's Lua and otherwise wouldn't know. + int owner = -1; + if (Value v = a->getAttr("owner")) + owner = (int)v; + client::NotifyActorAdded(a->getId(), a->get_traits().name, + a->m_actorinfo.pos, a->m_actorinfo.vel, + owner); if (get_id(a) == ac_pearl_white || get_id(a) == ac_pearl_black) ChangeMeditation(+1, 0, 0, 0); @@ -425,6 +434,10 @@ void World::add_actor(Actor *a, const V2 &pos) { Actor *World::yield_actor(Actor *a) { auto i = find(actorlist.begin(), actorlist.end(), a); if (i != actorlist.end()) { + // Tell the remote before erasing so its dispatcher can find + // the actor by id (the id outlives the actor briefly). + if (!preparing_level) + client::NotifyActorKilled(a->getId()); actorlist.erase(i); if (a->left == nullptr) @@ -1433,10 +1446,13 @@ void World::move_actors(double dtime) { rest_time -= dt; } - for (unsigned i = 0; i < nactors; ++i) { - Actor *a = actorlist[i]; + // Re-read actorlist size here: physics callbacks (Lua, Drop.cc, + // cannons) can add/yield actors, and using the original `nactors` + // would read past the end or miss new entries. Identity is the + // actor's Object id, which is stable across yield+append. + for (Actor *a : actorlist) { const ActorInfo &ai = *a->get_actorinfo(); - client::NotifyActorMoved(int(i), ai.pos, ai.vel); + client::NotifyActorMoved(a->getId(), ai.pos, ai.vel); } } @@ -1669,6 +1685,10 @@ void Resize(int w, int h) { display::NewWorld(w, h); server::WorldSized = true; player::NewGame(); + // Tell the remote so it can mirror the resize and rebuild its + // (empty) world to match. Subsequent SV_GRID_SPRITE and + // SV_ACTOR_ADDED events from the host repopulate the cells. + client::NotifyResize(w, h); } int Width() { @@ -1757,19 +1777,6 @@ V2 GetMouseForceForPlayer(Actor *a, int player) { return level->m_mouseforce.get_force_for_player(a, player); } -int FindActorIndex(Actor *a) { - if (!level) return -1; - for (size_t i = 0; i < level->actorlist.size(); ++i) - if (level->actorlist[i] == a) - return int(i); - return -1; -} - -Actor *GetActorByIndex(int idx) { - if (!level || idx < 0 || size_t(idx) >= level->actorlist.size()) - return nullptr; - return level->actorlist[idx]; -} void NameObject(Object *obj, const std::string &name) { string oldname; diff --git a/src/world.hh b/src/world.hh index 3bd781e8c..abf4a8b83 100644 --- a/src/world.hh +++ b/src/world.hh @@ -251,13 +251,6 @@ unsigned CountActorsOfKind(ActorID id); false if none were found. */ bool GetActorsInRange(ecl::V2 center, double range, std::vector &actors); -/*! Position of `a` in the world's actor list, or -1 if not present. - Stable for the duration of a level (actors are appended on creation - and never reordered), and used as the network identity for actor - updates so two peers don't need matching Object ids. */ -int FindActorIndex(Actor *a); -Actor *GetActorByIndex(int idx); - /*! Find all actors that are inside 'pos'. Returns false if none were found. */ bool GetActorsInsideField(const GridPos &pos, std::vector &actors); From 03363ddbdeb72ed6eb7941adb1d5d87b4ada0c95 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 13:17:44 +0200 Subject: [PATCH 09/13] add a host lobby with access-code auth and a join dialog Network menu now opens HostLobbyMenu / JoinLobbyMenu. The host sees a random 6-digit access code and the listening port, can browse packs and levels (with a network-only filter on by default), and the Start Game button lights up once a client has authenticated. Failed auth attempts are counted and the last source address is shown. Protocol bumped to 4: the client sends CL_AUTH (proto + code) right after connect, and the host replies SV_AUTH_OK or SV_AUTH_FAIL before the existing SV_HELLO/CL_HELLO exchange. netgame::Start was split into OpenHostLobby/ServiceHostLobby/StartHostedGame so the menu can keep the listener alive while the host is in the lobby UI. --- src/gui/MainMenu.cc | 424 +++++++++++++++++++++++++++++++++++++++++- src/gui/MainMenu.hh | 62 ++++++- src/netgame.cc | 438 +++++++++++++++++++++++++++++++++++--------- src/netgame.hh | 37 +++- 4 files changed, 862 insertions(+), 99 deletions(-) diff --git a/src/gui/MainMenu.cc b/src/gui/MainMenu.cc index ab6124d03..08f8e491e 100644 --- a/src/gui/MainMenu.cc +++ b/src/gui/MainMenu.cc @@ -27,6 +27,9 @@ #include "display.hh" #include "ecl_font.hh" #include "ecl_system.hh" +#include "ecl_util.hh" +#include "lev/Index.hh" +#include "lev/Proxy.hh" #include "main.hh" #include "nls.hh" #include "options.hh" @@ -47,10 +50,10 @@ namespace enigma { namespace gui { { const VMInfo *vminfo = video_engine->GetInfo(); - BuildVList b(this, Rect((vminfo->width - 150)/2,150,150,40), 5); - startgame = b.add(new StaticTextButton(N_("Start Game"), this)); + BuildVList b(this, Rect((vminfo->width - 200)/2, 200, 200, 40), 10); + m_hostgame = b.add(new StaticTextButton(N_("Host Game"), this)); m_joingame = b.add(new StaticTextButton(N_("Join Game"), this)); - m_back = b.add(new StaticTextButton(N_("Back"), this)); + m_back = b.add(new StaticTextButton(N_("Back"), this)); } NetworkMenu::~NetworkMenu () @@ -64,14 +67,17 @@ namespace enigma { namespace gui { void NetworkMenu::on_action(gui::Widget *w) { - if (w == startgame) { - netgame::Start(); - } - else if (w == m_joingame) { - netgame::Join("localhost", 12345); - } - if (w == m_back) + if (w == m_hostgame) { + HostLobbyMenu m; + m.manage(); + invalidate_all(); + } else if (w == m_joingame) { + JoinLobbyMenu m; + m.manage(); + invalidate_all(); + } else if (w == m_back) { Menu::quit(); + } } void NetworkMenu::draw_background(ecl::GC &gc) @@ -84,6 +90,404 @@ namespace enigma { namespace gui { { } + /* -------------------- Lobby helpers -------------------- */ + + namespace { + // Walk the current index to find the next (or previous) level + // whose proxy reports network mode. Returns the index of the + // found level, or -1 if there is none in the requested + // direction. dir = +1 for next, -1 for previous. + int find_network_level(lev::Index *ind, int start, int dir) { + if (!ind) return -1; + int sz = ind->size(); + for (int i = start; i >= 0 && i < sz; i += dir) { + lev::Proxy *p = ind->getProxy(i); + if (p == nullptr) continue; + try { + p->loadMetadata(true); + } catch (...) { + continue; + } + if (p->hasNetworkMode()) + return i; + } + return -1; + } + + bool proxy_is_network_mode(lev::Proxy *p) { + if (!p) return false; + try { + p->loadMetadata(true); + } catch (...) { + return false; + } + return p->hasNetworkMode(); + } + } + + /* -------------------- HostLobbyMenu -------------------- */ + + HostLobbyMenu::HostLobbyMenu() + : lbl_code(new Label("", HALIGN_LEFT)), + lbl_port(new Label("", HALIGN_LEFT)), + lbl_pack(new Label("", HALIGN_LEFT)), + lbl_level(new Label("", HALIGN_LEFT)), + lbl_status(new Label("", HALIGN_LEFT)), + lbl_failed(new Label("", HALIGN_LEFT)), + only_network_levels(true), + game_started(false) + { + const VMInfo *vminfo = video_engine->GetInfo(); + int w = vminfo->width; + int label_w = std::min(w - 80, 500); + int x0 = (w - label_w) / 2; + int y = 90; + int dy = 32; + + auto add_label = [&](Label *l) { + this->add(l, Rect(x0, y, label_w, 26)); + y += dy; + }; + + add_label(lbl_code); + add_label(lbl_port); + y += 8; + add_label(lbl_pack); + add_label(lbl_level); + + // Pack and level navigation. + int btn_y = y; + int btn_w = 90; + int btn_h = 28; + int gap = 8; + int row_w = btn_w * 5 + gap * 4; + int row_x = (w - row_w) / 2; + but_prev_pack = new StaticTextButton(N_("< Pack"), this); + but_next_pack = new StaticTextButton(N_("Pack >"), this); + but_prev_level = new StaticTextButton(N_("< Level"), this); + but_next_level = new StaticTextButton(N_("Level >"), this); + but_only_network = new StaticTextButton("", this); + this->add(but_prev_pack, Rect(row_x + 0*(btn_w+gap), btn_y, btn_w, btn_h)); + this->add(but_next_pack, Rect(row_x + 1*(btn_w+gap), btn_y, btn_w, btn_h)); + this->add(but_prev_level, Rect(row_x + 2*(btn_w+gap), btn_y, btn_w, btn_h)); + this->add(but_next_level, Rect(row_x + 3*(btn_w+gap), btn_y, btn_w, btn_h)); + this->add(but_only_network, Rect(row_x + 4*(btn_w+gap), btn_y, btn_w, btn_h)); + y = btn_y + btn_h + 16; + + add_label(lbl_status); + add_label(lbl_failed); + y += 12; + + int sb_w = 140; + int sb_h = 36; + int sb_gap = 20; + int sb_total = sb_w * 2 + sb_gap; + int sb_x = (w - sb_total) / 2; + but_start = new StaticTextButton(N_("Start Game"), this); + but_cancel = new StaticTextButton(N_("Cancel"), this); + this->add(but_start, Rect(sb_x, y, sb_w, sb_h)); + this->add(but_cancel, Rect(sb_x + sb_w + sb_gap, y, sb_w, sb_h)); + + // If the current selection isn't a network level and the + // filter is on, advance to the first network level. + if (only_network_levels) { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind) { + int pos = ind->getCurrentPosition(); + lev::Proxy *p = ind->getProxy(pos); + if (!proxy_is_network_mode(p)) { + int nxt = find_network_level(ind, 0, +1); + if (nxt >= 0) ind->setCurrentPosition(nxt); + } + } + } + + // Open the listener. If it fails (port in use), put the error + // into the status label; user can hit Cancel. + int port = 12345; + if (!netgame::OpenHostLobby(port)) { + lbl_status->set_text(_("Could not open listening port.")); + } + + update_level_label(); + update_status(); + } + + HostLobbyMenu::~HostLobbyMenu() { + if (!game_started) + netgame::CloseHostLobby(); + } + + bool HostLobbyMenu::current_level_is_network() { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind) return false; + return proxy_is_network_mode(ind->getProxy(ind->getCurrentPosition())); + } + + void HostLobbyMenu::update_level_label() { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind || ind->size() == 0) { + lbl_pack->set_text(_("Level pack: (none)")); + lbl_level->set_text(_("Level: (none)")); + } else { + int pos = ind->getCurrentPosition(); + lev::Proxy *p = ind->getProxy(pos); + std::string title = p ? p->getTitle() : "?"; + lbl_pack->set_text(ecl::strf(_("Level pack: %s"), + ind->getName().c_str())); + std::string suffix = current_level_is_network() + ? _(" [network]") + : _(" [single-player]"); + lbl_level->set_text(ecl::strf(_("Level: #%d - %s"), + pos + 1, title.c_str()) + suffix); + } + static_cast(but_only_network) + ->set_text(only_network_levels ? _("Net only: on") + : _("Net only: off")); + } + + void HostLobbyMenu::update_status() { + lbl_code->set_text(ecl::strf(_("Access code: %s"), + netgame::LobbyCode().c_str())); + lbl_port->set_text(ecl::strf(_("Listening on port %d"), + netgame::LobbyPort())); + if (netgame::LobbyHasReadyClient()) { + lbl_status->set_text(_("Client connected — press Start Game.")); + } else if (netgame::LobbyHasPendingClient()) { + lbl_status->set_text(_("Client connecting — waiting for code...")); + } else if (netgame::LobbyPort() != 0) { + lbl_status->set_text(_("Waiting for client...")); + } + int n = netgame::LobbyFailedAttempts(); + if (n == 0) { + lbl_failed->set_text(_("Failed attempts: 0")); + } else { + lbl_failed->set_text( + ecl::strf(_("Failed attempts: %d (last: %s)"), + n, netgame::LobbyLastFailReason().c_str())); + } + } + + bool HostLobbyMenu::on_event(const SDL_Event &e) { + return false; + } + + void HostLobbyMenu::on_action(gui::Widget *w) { + if (w == but_cancel) { + Menu::quit(); + return; + } + if (w == but_start) { + if (!netgame::LobbyHasReadyClient()) { + lbl_status->set_text(_("No client connected yet.")); + invalidate_all(); + return; + } + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind || ind->size() == 0) { + lbl_status->set_text(_("No level selected.")); + invalidate_all(); + return; + } + game_started = true; + std::string pack = ind->getName(); + int pos = ind->getCurrentPosition(); + // StartHostedGame takes over the listener and runs the + // whole game synchronously. When it returns, the lobby is + // already closed. + netgame::StartHostedGame(pack, pos); + Menu::quit(); + return; + } + if (w == but_prev_pack || w == but_next_pack) { + lev::Index *cur = lev::Index::getCurrentIndex(); + if (!cur) return; + lev::Index *target = (w == but_next_pack) + ? lev::Index::nextGroupIndex() + : lev::Index::previousGroupIndex(); + if (target && target != cur) + lev::Index::setCurrentIndex(target->getName()); + // If filter is on, snap to first network-mode level in the + // new pack. + if (only_network_levels) { + lev::Index *ind = lev::Index::getCurrentIndex(); + int nxt = find_network_level(ind, 0, +1); + if (nxt >= 0) + ind->setCurrentPosition(nxt); + } + update_level_label(); + invalidate_all(); + return; + } + if (w == but_prev_level || w == but_next_level) { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind || ind->size() == 0) return; + int dir = (w == but_next_level) ? +1 : -1; + int sz = ind->size(); + int pos = ind->getCurrentPosition(); + int start = pos + dir; + if (start < 0) start = 0; + if (start >= sz) start = sz - 1; + int found; + if (only_network_levels) { + found = find_network_level(ind, start, dir); + } else { + found = (start >= 0 && start < sz) ? start : -1; + } + if (found >= 0) { + ind->setCurrentPosition(found); + update_level_label(); + invalidate_all(); + } + return; + } + if (w == but_only_network) { + only_network_levels = !only_network_levels; + if (only_network_levels && !current_level_is_network()) { + lev::Index *ind = lev::Index::getCurrentIndex(); + int nxt = find_network_level(ind, 0, +1); + if (nxt >= 0) ind->setCurrentPosition(nxt); + } + update_level_label(); + invalidate_all(); + return; + } + } + + void HostLobbyMenu::draw_background(ecl::GC &gc) { + set_caption(_("Enigma - Host Lobby")); + const VMInfo *vminfo = video_engine->GetInfo(); + blit(gc, vminfo->mbg_offsetx, vminfo->mbg_offsety, + enigma::GetImage("menu_bg", ".jpg")); + Font *f = enigma::GetFont("menufontsel"); + std::string title = _("Host Lobby"); + int tw = f->get_width(title.c_str()); + f->render(gc, (vminfo->width - tw) / 2, 40, title.c_str()); + } + + void HostLobbyMenu::tick(double /*dtime*/) { + if (game_started) return; + netgame::ServiceHostLobby(); + static double accu = 0; + accu += 0.01; + if (accu >= 0.2) { + accu = 0; + update_status(); + invalidate_all(); + } + } + + /* -------------------- JoinLobbyMenu -------------------- */ + + JoinLobbyMenu::JoinLobbyMenu() + : tf_host(new TextField("localhost")), + tf_port(new TextField("12345")), + tf_code(new TextField("")), + lbl_status(new Label("", HALIGN_LEFT)) + { + const VMInfo *vminfo = video_engine->GetInfo(); + int w = vminfo->width; + + int label_w = 90; + int field_w = 260; + int row_w = label_w + 10 + field_w; + int row_x = (w - row_w) / 2; + int y = 130; + int row_h = 36; + int row_gap = 14; + + auto add_field = [&](const char *labeltext, TextField *tf) { + Label *l = new Label(labeltext, HALIGN_RIGHT); + this->add(l, Rect(row_x, y, label_w, row_h)); + this->add(tf, Rect(row_x + label_w + 10, y, field_w, row_h)); + y += row_h + row_gap; + }; + add_field(N_("Host:"), tf_host); + add_field(N_("Port:"), tf_port); + add_field(N_("Code:"), tf_code); + + tf_port->setInvalidChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!?"); + tf_port->setMaxChars(5); + tf_code->setMaxChars(6); + tf_code->setInvalidChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!?@./:"); + + y += 6; + this->add(lbl_status, Rect(row_x, y, row_w, row_h)); + y += row_h + 16; + + int sb_w = 140; + int sb_h = 36; + int sb_gap = 20; + int sb_total = sb_w * 2 + sb_gap; + int sb_x = (w - sb_total) / 2; + but_connect = new StaticTextButton(N_("Connect"), this); + but_back = new StaticTextButton(N_("Back"), this); + this->add(but_connect, Rect(sb_x, y, sb_w, sb_h)); + this->add(but_back, Rect(sb_x + sb_w + sb_gap, y, sb_w, sb_h)); + + std::string last_err = netgame::LastJoinError(); + if (!last_err.empty()) + lbl_status->set_text(_("Last error: ") + last_err); + } + + JoinLobbyMenu::~JoinLobbyMenu() { + } + + bool JoinLobbyMenu::on_event(const SDL_Event &e) { + return false; + } + + void JoinLobbyMenu::do_connect() { + std::string host = tf_host->getText(); + std::string portstr = tf_port->getText(); + std::string code = tf_code->getText(); + int port = std::atoi(portstr.c_str()); + if (host.empty()) { + lbl_status->set_text(_("Please enter a host name.")); + invalidate_all(); + return; + } + if (code.size() != 6) { + lbl_status->set_text(_("Code must be 6 digits.")); + invalidate_all(); + return; + } + lbl_status->set_text(_("Connecting...")); + draw_all(); + refresh(); + netgame::Join(host, port, code); + std::string err = netgame::LastJoinError(); + if (err.empty()) { + // Game ran to completion; close this menu. + Menu::quit(); + } else { + lbl_status->set_text(err); + invalidate_all(); + } + } + + void JoinLobbyMenu::on_action(gui::Widget *w) { + if (w == but_connect) { + do_connect(); + } else if (w == but_back) { + Menu::quit(); + } + } + + void JoinLobbyMenu::draw_background(ecl::GC &gc) { + set_caption(_("Enigma - Join Game")); + const VMInfo *vminfo = video_engine->GetInfo(); + blit(gc, vminfo->mbg_offsetx, vminfo->mbg_offsety, + enigma::GetImage("menu_bg", ".jpg")); + Font *f = enigma::GetFont("menufontsel"); + std::string title = _("Join Game"); + int tw = f->get_width(title.c_str()); + f->render(gc, (vminfo->width - tw) / 2, 40, title.c_str()); + } + + void JoinLobbyMenu::tick(double /*dtime*/) { + } + /* -------------------- Help menu -------------------- */ static const char *credit_text[] = { diff --git a/src/gui/MainMenu.hh b/src/gui/MainMenu.hh index fff8719fb..c0f1af482 100644 --- a/src/gui/MainMenu.hh +++ b/src/gui/MainMenu.hh @@ -22,6 +22,7 @@ #include "gui/Menu.hh" #include "gui/widgets.hh" +#include "gui/TextField.hh" #include #include @@ -88,7 +89,7 @@ namespace enigma { namespace gui { }; /* -------------------- NetworkMenu -------------------- */ - + class NetworkMenu : public gui::Menu { public: NetworkMenu (); @@ -103,12 +104,67 @@ namespace enigma { namespace gui { void tick(double dtime); // Variables. - gui::Widget *startgame; + gui::Widget *m_hostgame; gui::Widget *m_joingame; gui::Widget *m_back; }; - +/* -------------------- HostLobbyMenu -------------------- */ + + class HostLobbyMenu : public gui::Menu { + public: + HostLobbyMenu(); + ~HostLobbyMenu(); + private: + bool on_event(const SDL_Event &e) override; + void on_action(gui::Widget *w) override; + void draw_background(ecl::GC &gc) override; + void tick(double dtime) override; + + void update_status(); + void update_level_label(); + bool current_level_is_network(); + + gui::Label *lbl_code; + gui::Label *lbl_port; + gui::Label *lbl_pack; + gui::Label *lbl_level; + gui::Label *lbl_status; + gui::Label *lbl_failed; + gui::Widget *but_prev_pack; + gui::Widget *but_next_pack; + gui::Widget *but_prev_level; + gui::Widget *but_next_level; + gui::Widget *but_only_network; + gui::Widget *but_start; + gui::Widget *but_cancel; + + bool only_network_levels; + bool game_started; + }; + +/* -------------------- JoinLobbyMenu -------------------- */ + + class JoinLobbyMenu : public gui::Menu { + public: + JoinLobbyMenu(); + ~JoinLobbyMenu(); + private: + bool on_event(const SDL_Event &e) override; + void on_action(gui::Widget *w) override; + void draw_background(ecl::GC &gc) override; + void tick(double dtime) override; + + void do_connect(); + + gui::TextField *tf_host; + gui::TextField *tf_port; + gui::TextField *tf_code; + gui::Label *lbl_status; + gui::Widget *but_connect; + gui::Widget *but_back; + }; + /* -------------------- Functions -------------------- */ void ShowMainMenu(); void ShowNetworkMenu(); diff --git a/src/netgame.cc b/src/netgame.cc index a68a6b480..2fef46fa4 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -40,6 +40,7 @@ #include "SDL.h" #include +#include #include #include @@ -53,7 +54,7 @@ using namespace enigma; namespace { -constexpr Uint16 PROTO_VERSION = 3; +constexpr Uint16 PROTO_VERSION = 4; constexpr int NET_PORT = 12345; // Packet tags. Client -> host inputs live in the 0x10–0x7F band, host @@ -62,6 +63,7 @@ constexpr int NET_PORT = 12345; enum ProtoTag : Uint8 { // Client -> host (inputs) CL_HELLO = 0x01, + CL_AUTH = 0x02, CL_MOUSE_FORCE = 0x10, CL_ACTIVATE_ITEM = 0x11, CL_ROTATE_INVENTORY = 0x12, @@ -69,6 +71,8 @@ enum ProtoTag : Uint8 { CL_INHIBIT_PICKUP = 0x14, // Host -> client (outbound EventSink stream) + SV_AUTH_OK = 0x7D, + SV_AUTH_FAIL = 0x7E, SV_HELLO = 0x7F, SV_COMMAND = 0x80, SV_ADVANCE_LEVEL = 0x81, @@ -691,65 +695,292 @@ void remote_main_loop(Peer *peer) { } // anonymous namespace -void netgame::Start() { - // Host plays whatever level the user has currently selected in the - // level browser. A proper lobby (host/port/level/color picker) is - // still missing. - lev::Index *ind = lev::Index::getCurrentIndex(); - if (ind == nullptr) { - fprintf(stderr, "SV: no current level pack selected.\n"); - return; - } - int level_pos = ind->getCurrentPosition(); - lev::Proxy *proxy = ind->getProxy(level_pos); - if (proxy == nullptr) { - fprintf(stderr, "SV: no current level selected.\n"); - return; +//====================================================================== +// Host lobby (pre-game, holds the connection while the host is in the +// lobby UI, runs the access-code handshake) +//====================================================================== + +namespace { + +struct LobbyState { + ENetHost *enet_host = nullptr; + int port = 0; + std::string code; // 6 digits, generated on OpenHostLobby + + // Pending: ENet-connected but not yet authenticated. Held until + // CL_AUTH arrives or the auth timeout expires. + ENetPeer *pending_peer = nullptr; + Uint32 pending_since_ms = 0; + + // Authenticated client, parked until the host clicks Start. Wrapped + // as a Peer so the existing game code can use it unchanged. + Peer *ready_peer = nullptr; + ENetPeer *ready_raw = nullptr; + + int failed_attempts = 0; + std::string last_fail_reason; +}; + +LobbyState s_lobby; +std::string s_last_join_error; + +std::string format_addr(const ENetAddress &a) { + return ecl::strf("%u.%u.%u.%u:%u", + unsigned( a.host & 0xff), + unsigned((a.host >> 8) & 0xff), + unsigned((a.host >> 16) & 0xff), + unsigned((a.host >> 24) & 0xff), + unsigned(a.port)); +} + +std::string generate_code() { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution d(0, 999999); + char buf[8]; + snprintf(buf, sizeof(buf), "%06d", d(gen)); + return std::string(buf); +} + +// Send a one-packet message to an ENet peer that we are about to +// disconnect, then schedule the disconnect with a short timeout so the +// packet has a chance to reach the client. +void send_and_disconnect(ENetPeer *peer, const ecl::Buffer &buf) { + ENetPacket *pkt = enet_packet_create(buf.data(), buf.size(), + ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(peer, CHANNEL_RELIABLE, pkt); + enet_host_flush(s_lobby.enet_host); + enet_peer_disconnect(peer, 0); +} + +void record_failed_attempt(ENetPeer *peer, const std::string &reason) { + s_lobby.failed_attempts++; + s_lobby.last_fail_reason = + ecl::strf("%s from %s", + reason.c_str(), + format_addr(peer->address).c_str()); + fprintf(stderr, "SV: rejected auth attempt (#%d): %s\n", + s_lobby.failed_attempts, + s_lobby.last_fail_reason.c_str()); +} + +void drop_pending(const std::string &reason) { + if (!s_lobby.pending_peer) return; + record_failed_attempt(s_lobby.pending_peer, reason); + ecl::Buffer fail; + fail << Uint8(SV_AUTH_FAIL) << reason; + send_and_disconnect(s_lobby.pending_peer, fail); + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; +} + +} // anonymous namespace + +bool netgame::OpenHostLobby(int port) { + if (s_lobby.enet_host != nullptr) { + fprintf(stderr, "SV: lobby already open.\n"); + return false; } - printf("SV: hosting level pack '%s' level %d (%s)\n", - ind->getName().c_str(), level_pos + 1, - proxy->getTitle().c_str()); + if (port <= 0) port = NET_PORT; ENetAddress network_address; network_address.host = ENET_HOST_ANY; - network_address.port = NET_PORT; + network_address.port = port; - ENetHost *network_host = + ENetHost *h = #ifdef ENET_VER_EQ_GT_13 enet_host_create(&network_address, 1, 2, 0, 0); #else enet_host_create(&network_address, 1, 0, 0); #endif - if (network_host == nullptr) { - fprintf(stderr, "SV: failed to create an ENet host on port %d.\n", NET_PORT); - return; + if (h == nullptr) { + fprintf(stderr, "SV: failed to create ENet host on port %d.\n", port); + return false; + } + s_lobby = LobbyState{}; + s_lobby.enet_host = h; + s_lobby.port = port; + s_lobby.code = generate_code(); + printf("SV: lobby open on port %d, access code %s\n", + port, s_lobby.code.c_str()); + return true; +} + +void netgame::CloseHostLobby() { + if (s_lobby.enet_host == nullptr) return; + if (s_lobby.pending_peer) { + enet_peer_disconnect(s_lobby.pending_peer, 0); + s_lobby.pending_peer = nullptr; + } + if (s_lobby.ready_peer) { + s_lobby.ready_peer->disconnect(); + delete s_lobby.ready_peer; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; + } + // Pump one final round so disconnect packets get out. + ENetEvent ev; + Uint32 deadline = SDL_GetTicks() + 200; + while (SDL_GetTicks() < deadline && + enet_host_service(s_lobby.enet_host, &ev, 10) > 0) { + if (ev.type == ENET_EVENT_TYPE_RECEIVE) + enet_packet_destroy(ev.packet); + } + enet_host_destroy(s_lobby.enet_host); + s_lobby = LobbyState{}; +} + +void netgame::ServiceHostLobby() { + if (s_lobby.enet_host == nullptr) return; + + // Drop pending peers that take too long to send their CL_AUTH. + if (s_lobby.pending_peer && + SDL_GetTicks() - s_lobby.pending_since_ms > 5000) { + drop_pending("auth timeout"); } - Peer *peer = nullptr; - printf("SV: waiting for client on port %d (press ESC to cancel)...\n", NET_PORT); - while (peer == nullptr) { - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) { - enet_host_destroy(network_host); - return; + ENetEvent ev; + while (enet_host_service(s_lobby.enet_host, &ev, 0) > 0) { + switch (ev.type) { + case ENET_EVENT_TYPE_CONNECT: + if (s_lobby.ready_peer || s_lobby.pending_peer) { + // Already have someone — reject this one immediately. + fprintf(stderr, "SV: refused extra connection from %s\n", + format_addr(ev.peer->address).c_str()); + ecl::Buffer fail; + fail << Uint8(SV_AUTH_FAIL) << std::string("lobby busy"); + send_and_disconnect(ev.peer, fail); + break; + } + s_lobby.pending_peer = ev.peer; + s_lobby.pending_since_ms = SDL_GetTicks(); + printf("SV: connection from %s, awaiting code...\n", + format_addr(ev.peer->address).c_str()); + break; + + case ENET_EVENT_TYPE_RECEIVE: { + ecl::Buffer b; + b.assign(reinterpret_cast(ev.packet->data), + ev.packet->dataLength); + enet_packet_destroy(ev.packet); + + if (ev.peer == s_lobby.pending_peer) { + Uint8 tag = 0; + Uint16 proto = 0; + std::string supplied_code; + b >> tag >> proto >> supplied_code; + if (tag != CL_AUTH) { + drop_pending("malformed auth"); + break; + } + if (proto != PROTO_VERSION) { + drop_pending(ecl::strf("protocol mismatch (got %d, want %d)", + int(proto), int(PROTO_VERSION))); + break; + } + if (supplied_code != s_lobby.code) { + drop_pending("wrong code"); + break; + } + // Auth OK. + ecl::Buffer ok; + ok << Uint8(SV_AUTH_OK); + ENetPacket *pkt = enet_packet_create( + ok.data(), ok.size(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(s_lobby.pending_peer, + CHANNEL_RELIABLE, pkt); + enet_host_flush(s_lobby.enet_host); + + s_lobby.ready_raw = s_lobby.pending_peer; + s_lobby.ready_peer = new Peer_Enet(s_lobby.enet_host, + s_lobby.pending_peer, 2); + printf("SV: client authenticated from %s\n", + format_addr(s_lobby.pending_peer->address).c_str()); + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; } + // Stray traffic from the ready peer before game-start is + // ignored. They won't have a sink to receive yet. + break; } - ENetEvent event; - while (enet_host_service(network_host, &event, 0) > 0) { - if (event.type == ENET_EVENT_TYPE_CONNECT) { - printf("SV: client connected.\n"); - peer = new Peer_Enet(network_host, event.peer, 2); + + case ENET_EVENT_TYPE_DISCONNECT: + if (ev.peer == s_lobby.pending_peer) { + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; + } else if (ev.peer == s_lobby.ready_raw) { + fprintf(stderr, "SV: authenticated client disconnected before game start.\n"); + delete s_lobby.ready_peer; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; } + break; + + default: + break; } - SDL_Delay(10); + } +} + +int netgame::LobbyPort() { return s_lobby.port; } +std::string netgame::LobbyCode() { return s_lobby.code; } +bool netgame::LobbyHasReadyClient() { return s_lobby.ready_peer != nullptr; } +bool netgame::LobbyHasPendingClient() { return s_lobby.pending_peer != nullptr; } +int netgame::LobbyFailedAttempts() { return s_lobby.failed_attempts; } +std::string netgame::LobbyLastFailReason() { return s_lobby.last_fail_reason; } + +//====================================================================== +// Game start (host) — promote the lobbied peer to a running session +//====================================================================== + +void netgame::StartHostedGame(const std::string &level_pack, int level_pos) { + if (s_lobby.enet_host == nullptr || s_lobby.ready_peer == nullptr) { + fprintf(stderr, "SV: StartHostedGame called without a ready client.\n"); + return; } + // Transfer ownership of the ENet host and peer out of the lobby so + // CloseHostLobby() doesn't touch them after we return. + ENetHost *network_host = s_lobby.enet_host; + Peer *peer = s_lobby.ready_peer; + s_lobby.enet_host = nullptr; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; + int failed_during_lobby = s_lobby.failed_attempts; + s_lobby = LobbyState{}; + + if (!lev::Index::setCurrentIndex(level_pack)) { + fprintf(stderr, "SV: missing level pack '%s'.\n", level_pack.c_str()); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + lev::Index *ind = lev::Index::getCurrentIndex(); + if (level_pos < 0 || level_pos >= ind->size()) { + fprintf(stderr, "SV: invalid level position %d in pack '%s'.\n", + level_pos, level_pack.c_str()); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + ind->setCurrentPosition(level_pos); + lev::Proxy *proxy = ind->getProxy(level_pos); + if (proxy == nullptr) { + fprintf(stderr, "SV: no current level selected.\n"); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + printf("SV: hosting level pack '%s' level %d (%s); %d failed auth attempts during lobby\n", + ind->getName().c_str(), level_pos + 1, + proxy->getTitle().c_str(), failed_during_lobby); + Uint32 seed = Uint32(SDL_GetTicks()) ^ Uint32(uintptr_t(peer)); const int remote_player = 1; - // Mark the session as active *before* loading so per-load logic - // (like AddYinYang) can suppress single-computer behaviour. s_in_session = true; SessionGuard guard(/*client=*/false); @@ -789,19 +1020,11 @@ void netgame::Start() { } } - // Register the sink BEFORE the initial load so every event fired - // during load — SV_RESIZE, SV_GRID_SPRITE, SV_ACTOR_ADDED, - // SV_INVENTORY, SV_LEVEL_LOADED — reaches the remote. The remote - // builds its world entirely from this event stream. SinkGuard sink_guard(&sink); - ind->setCurrentPosition(level_pos); server::RandomState = Sint32(seed); server::Msg_LoadLevel(proxy, false); - // Grab the mouse and hide the cursor like single-player. Pass - // --nograb to keep both visible when debugging two peers on the - // same machine. video_engine->HideMouse(); ScopedInputGrab grab(not enigma::Nograb); @@ -817,9 +1040,38 @@ void netgame::Start() { enet_host_destroy(network_host); } -void netgame::Join(std::string hostname, int port) { - if (port <= 0) - port = NET_PORT; +//====================================================================== +// Client join +//====================================================================== + +std::string netgame::LastJoinError() { + return s_last_join_error; +} + +namespace { + +// Helper to bail out of Join with cleanup and a stored error reason. +struct JoinAbort { + Peer **peer; + ENetHost *host; + JoinAbort(Peer **p, ENetHost *h) : peer(p), host(h) {} + void operator()(const std::string &reason) { + s_last_join_error = reason; + fprintf(stderr, "CL: %s\n", reason.c_str()); + if (*peer) { + (*peer)->disconnect(); + delete *peer; + *peer = nullptr; + } + if (host) enet_host_destroy(host); + } +}; + +} // namespace + +void netgame::Join(std::string hostname, int port, std::string code) { + if (port <= 0) port = NET_PORT; + s_last_join_error.clear(); printf("CL: connecting to %s:%d...\n", hostname.c_str(), port); ENetHost *network_host = enet_host_create(nullptr, 1, @@ -828,12 +1080,18 @@ void netgame::Join(std::string hostname, int port) { #endif 0, 0); if (network_host == nullptr) { - fprintf(stderr, "CL: failed to create ENet client host.\n"); + s_last_join_error = "failed to create ENet client host"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); return; } ENetAddress sv_address; - enet_address_set_host(&sv_address, hostname.c_str()); + if (enet_address_set_host(&sv_address, hostname.c_str()) != 0) { + s_last_join_error = "could not resolve host '" + hostname + "'"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); + enet_host_destroy(network_host); + return; + } sv_address.port = port; ENetPeer *raw_peer = @@ -843,7 +1101,8 @@ void netgame::Join(std::string hostname, int port) { enet_host_connect(network_host, &sv_address, 2); #endif if (raw_peer == nullptr) { - fprintf(stderr, "CL: no available peers for connection.\n"); + s_last_join_error = "no available peers for connection"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); enet_host_destroy(network_host); return; } @@ -851,20 +1110,50 @@ void netgame::Join(std::string hostname, int port) { ENetEvent event; if (enet_host_service(network_host, &event, 5000) <= 0 || event.type != ENET_EVENT_TYPE_CONNECT) { - fprintf(stderr, "CL: connection to %s:%d failed.\n", hostname.c_str(), port); + s_last_join_error = ecl::strf("could not connect to %s:%d", + hostname.c_str(), port); + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); enet_peer_reset(raw_peer); enet_host_destroy(network_host); return; } Peer *peer = new Peer_Enet(network_host, raw_peer, 0); + JoinAbort abort(&peer, network_host); + + // Send our access code right away. The host validates it before + // anything else and will reply SV_AUTH_OK or SV_AUTH_FAIL. + { + ecl::Buffer auth; + auth << Uint8(CL_AUTH) << PROTO_VERSION << code; + peer->send_reliable(auth, CHANNEL_RELIABLE); + } - // Receive SV_HELLO and parse. + ecl::Buffer auth_reply; + if (!wait_for_packet(peer, auth_reply, /*timeout_ms=*/10000)) { + abort("auth response timed out"); + return; + } + { + Uint8 atag = 0; + auth_reply >> atag; + if (atag == SV_AUTH_FAIL) { + std::string reason; + auth_reply >> reason; + abort("rejected by host: " + reason); + return; + } + if (atag != SV_AUTH_OK) { + abort(ecl::strf("unexpected auth tag 0x%x", int(atag))); + return; + } + } + printf("CL: code accepted, waiting for host to start the game...\n"); + + // Now wait for SV_HELLO. This may take a while — the host is + // sitting in their lobby until they click Start. Use a long timeout. ecl::Buffer hello; - if (!wait_for_packet(peer, hello, /*timeout_ms=*/10000)) { - fprintf(stderr, "CL: handshake timed out.\n"); - peer->disconnect(); - delete peer; - enet_host_destroy(network_host); + if (!wait_for_packet(peer, hello, /*timeout_ms=*/120000)) { + abort("host did not start the game in time"); return; } Uint8 tag = 0; @@ -875,11 +1164,8 @@ void netgame::Join(std::string hostname, int port) { Uint8 assigned_player = 1; hello >> tag >> proto >> level_pack >> level_idx >> seed >> assigned_player; if (tag != SV_HELLO || proto != PROTO_VERSION) { - fprintf(stderr, "CL: bad SV_HELLO (tag=0x%x proto=%d, expected %d).\n", - tag, int(proto), int(PROTO_VERSION)); - peer->disconnect(); - delete peer; - enet_host_destroy(network_host); + abort(ecl::strf("bad SV_HELLO (tag=0x%x proto=%d, expected %d)", + tag, int(proto), int(PROTO_VERSION))); return; } printf("CL: host wants level pack '%s' level %d, seed=0x%x, " @@ -887,23 +1173,14 @@ void netgame::Join(std::string hostname, int port) { level_pack.c_str(), int(level_idx) + 1, unsigned(seed), int(assigned_player)); - // The remote needs the level pack present only to read level - // titles for the window caption — it does not load the level - // itself, the host streams the populated world. if (!lev::Index::setCurrentIndex(level_pack)) { - fprintf(stderr, "CL: missing level pack '%s'.\n", level_pack.c_str()); - peer->disconnect(); - delete peer; - enet_host_destroy(network_host); + abort("missing level pack '" + level_pack + "'"); return; } lev::Index *ind = lev::Index::getCurrentIndex(); if (int(level_idx) >= ind->size()) { - fprintf(stderr, "CL: level pack only has %d levels, host asked for %d.\n", - ind->size(), int(level_idx)); - peer->disconnect(); - delete peer; - enet_host_destroy(network_host); + abort(ecl::strf("level pack only has %d levels, host asked for %d", + ind->size(), int(level_idx))); return; } @@ -913,23 +1190,16 @@ void netgame::Join(std::string hostname, int port) { SessionGuard guard(/*client=*/true); server::RandomState = Sint32(seed); - // Reset client state from any prior game (cls_abort etc.) so the - // remote_main_loop's AbortGameP() check doesn't trip on startup. - // SV_LEVEL_LOADED will move us to cls_preparing_game shortly. client::Stop(); apply_reload(int(level_idx)); player::SetCurrentPlayer(assigned_player); - // Acknowledge. { ecl::Buffer ack; ack << Uint8(CL_HELLO) << PROTO_VERSION; peer->send_reliable(ack, CHANNEL_RELIABLE); } - // Grab the mouse and hide the cursor like single-player. Pass - // --nograb to keep both visible when debugging two peers on the - // same machine. video_engine->HideMouse(); ScopedInputGrab grab(not enigma::Nograb); diff --git a/src/netgame.hh b/src/netgame.hh index 86f42f937..b92e3f984 100644 --- a/src/netgame.hh +++ b/src/netgame.hh @@ -25,8 +25,41 @@ namespace netgame { -void Start(); -void Join(std::string hostname, int port); +// Open a host listener on the given port. Generates a fresh 6-digit +// access code, returned via LobbyCode(). Returns false if the listener +// could not be created (e.g. port already in use). +bool OpenHostLobby(int port); + +// Shut down the host listener. Disconnects any pending or authenticated +// peer that wasn't promoted to a game via StartHostedGame(). +void CloseHostLobby(); + +// Drive the host's listener once: accept new connections, validate +// authentication, count failed attempts. Call from the lobby menu tick. +void ServiceHostLobby(); + +// Lobby state observers. +int LobbyPort(); +std::string LobbyCode(); +bool LobbyHasReadyClient(); // an authenticated client is waiting +bool LobbyHasPendingClient(); // someone connected but hasn't authed yet +int LobbyFailedAttempts(); +std::string LobbyLastFailReason(); + +// Take the lobbied peer and run the actual game with the chosen level. +// CloseHostLobby is implicit on return. +void StartHostedGame(const std::string &level_pack, int level_pos); + +// Connect to a host as a remote client. The code is sent as part of the +// authentication step; an empty code is rejected by any host that has a +// code set. On auth failure Join() returns immediately; on success it +// blocks for the duration of the game. +void Join(std::string hostname, int port, std::string code); + +// On client side: error reason from the most recent Join() that failed +// before the game started. Empty if the last Join succeeded (or if no +// Join has been attempted). +std::string LastJoinError(); // True while inside a Join() session (we are a remote client of some // host). False during Start() and during ordinary single-player play. From 64210434add85fa4786625ae6dcc738798a56630 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 14:52:12 +0200 Subject: [PATCH 10/13] use LevelWidget in the host lobby; fix TextField off-by-one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextField::setMaxChars limited input to maxChars-1 because the overflow check ran on the post-insert length with >= instead of >. The join dialog's 6-digit code field only accepted 5 digits because of this. The other caller (username, maxChars=20) gets one more character. The host lobby now embeds the full LevelWidget instead of the prev/next-level buttons + the small preview I had inlined. Same code the regular level menu uses, so it brings score icons, mouse hover, arrow / page navigation, and the grid of preview thumbnails for free. Clicking a level fires our on_action with the widget as sender; we treat that as "this level is now picked" and refresh the label — unlike LevelMenu, we do not launch the game. The Start Game button is the only path to actually start. --- src/gui/MainMenu.cc | 180 +++++++++++++++---------------------------- src/gui/MainMenu.hh | 7 +- src/gui/TextField.cc | 2 +- 3 files changed, 65 insertions(+), 124 deletions(-) diff --git a/src/gui/MainMenu.cc b/src/gui/MainMenu.cc index 08f8e491e..46251970f 100644 --- a/src/gui/MainMenu.cc +++ b/src/gui/MainMenu.cc @@ -18,6 +18,7 @@ #include "gui/MainMenu.hh" #include "gui/LevelMenu.hh" +#include "gui/LevelWidget.hh" #include "gui/SearchMenu.hh" #include "gui/OptionsMenu.hh" #include "gui/InfoMenu.hh" @@ -93,27 +94,6 @@ namespace enigma { namespace gui { /* -------------------- Lobby helpers -------------------- */ namespace { - // Walk the current index to find the next (or previous) level - // whose proxy reports network mode. Returns the index of the - // found level, or -1 if there is none in the requested - // direction. dir = +1 for next, -1 for previous. - int find_network_level(lev::Index *ind, int start, int dir) { - if (!ind) return -1; - int sz = ind->size(); - for (int i = start; i >= 0 && i < sz; i += dir) { - lev::Proxy *p = ind->getProxy(i); - if (p == nullptr) continue; - try { - p->loadMetadata(true); - } catch (...) { - continue; - } - if (p->hasNetworkMode()) - return i; - } - return -1; - } - bool proxy_is_network_mode(lev::Proxy *p) { if (!p) return false; try { @@ -129,78 +109,67 @@ namespace enigma { namespace gui { HostLobbyMenu::HostLobbyMenu() : lbl_code(new Label("", HALIGN_LEFT)), - lbl_port(new Label("", HALIGN_LEFT)), + lbl_port(new Label("", HALIGN_RIGHT)), lbl_pack(new Label("", HALIGN_LEFT)), lbl_level(new Label("", HALIGN_LEFT)), lbl_status(new Label("", HALIGN_LEFT)), lbl_failed(new Label("", HALIGN_LEFT)), - only_network_levels(true), + levelwidget(new LevelWidget(/*withScoreIcons=*/true, + /*withEditBorder=*/false)), game_started(false) { const VMInfo *vminfo = video_engine->GetInfo(); int w = vminfo->width; - int label_w = std::min(w - 80, 500); - int x0 = (w - label_w) / 2; - int y = 90; - int dy = 32; - - auto add_label = [&](Label *l) { - this->add(l, Rect(x0, y, label_w, 26)); - y += dy; - }; - - add_label(lbl_code); - add_label(lbl_port); - y += 8; - add_label(lbl_pack); - add_label(lbl_level); - - // Pack and level navigation. - int btn_y = y; - int btn_w = 90; - int btn_h = 28; - int gap = 8; - int row_w = btn_w * 5 + gap * 4; - int row_x = (w - row_w) / 2; - but_prev_pack = new StaticTextButton(N_("< Pack"), this); - but_next_pack = new StaticTextButton(N_("Pack >"), this); - but_prev_level = new StaticTextButton(N_("< Level"), this); - but_next_level = new StaticTextButton(N_("Level >"), this); - but_only_network = new StaticTextButton("", this); - this->add(but_prev_pack, Rect(row_x + 0*(btn_w+gap), btn_y, btn_w, btn_h)); - this->add(but_next_pack, Rect(row_x + 1*(btn_w+gap), btn_y, btn_w, btn_h)); - this->add(but_prev_level, Rect(row_x + 2*(btn_w+gap), btn_y, btn_w, btn_h)); - this->add(but_next_level, Rect(row_x + 3*(btn_w+gap), btn_y, btn_w, btn_h)); - this->add(but_only_network, Rect(row_x + 4*(btn_w+gap), btn_y, btn_w, btn_h)); - y = btn_y + btn_h + 16; - - add_label(lbl_status); - add_label(lbl_failed); - y += 12; + int h = vminfo->height; + int margin = 20; + int label_h = 26; + + // Top row: code on the left, port on the right. + int top_y = 50; + this->add(lbl_code, Rect(margin, top_y, (w-2*margin)/2, label_h)); + this->add(lbl_port, Rect(w/2, top_y, (w-2*margin)/2, label_h)); + + // Pack row with prev/next buttons. + int pack_y = top_y + label_h + 8; + int btn_w = 80; + int btn_h = label_h; + but_prev_pack = new StaticTextButton(N_("< Pack"), this); + but_next_pack = new StaticTextButton(N_("Pack >"), this); + int pack_label_x = margin; + int pack_label_w = w - 2*margin - 2*(btn_w + 6); + this->add(lbl_pack, Rect(pack_label_x, pack_y, pack_label_w, btn_h)); + this->add(but_prev_pack, Rect(pack_label_x + pack_label_w + 6, + pack_y, btn_w, btn_h)); + this->add(but_next_pack, Rect(pack_label_x + pack_label_w + 6 + btn_w + 6, + pack_y, btn_w, btn_h)); + + // Level grid: takes the bulk of the remaining vertical space. + int grid_y = pack_y + btn_h + 8; + int bottom_block_h = 3 * (label_h + 6) + 40 + margin; + int grid_h = std::max(120, h - grid_y - bottom_block_h); + Rect grid_area(margin, grid_y, w - 2*margin, grid_h); + levelwidget->set_listener(this); + levelwidget->realize(grid_area); + levelwidget->set_area(grid_area); + this->add(levelwidget); + + // Below the grid: selected-level label, then status + failed, + // then Start/Cancel buttons. + int info_y = grid_y + grid_h + 6; + this->add(lbl_level, Rect(margin, info_y, w - 2*margin, label_h)); + this->add(lbl_status, Rect(margin, info_y + label_h + 4, w - 2*margin, label_h)); + this->add(lbl_failed, Rect(margin, info_y + 2*(label_h + 4), w - 2*margin, label_h)); int sb_w = 140; int sb_h = 36; int sb_gap = 20; + int sb_y = h - sb_h - margin; int sb_total = sb_w * 2 + sb_gap; int sb_x = (w - sb_total) / 2; but_start = new StaticTextButton(N_("Start Game"), this); but_cancel = new StaticTextButton(N_("Cancel"), this); - this->add(but_start, Rect(sb_x, y, sb_w, sb_h)); - this->add(but_cancel, Rect(sb_x + sb_w + sb_gap, y, sb_w, sb_h)); - - // If the current selection isn't a network level and the - // filter is on, advance to the first network level. - if (only_network_levels) { - lev::Index *ind = lev::Index::getCurrentIndex(); - if (ind) { - int pos = ind->getCurrentPosition(); - lev::Proxy *p = ind->getProxy(pos); - if (!proxy_is_network_mode(p)) { - int nxt = find_network_level(ind, 0, +1); - if (nxt >= 0) ind->setCurrentPosition(nxt); - } - } - } + this->add(but_start, Rect(sb_x, sb_y, sb_w, sb_h)); + this->add(but_cancel, Rect(sb_x + sb_w + sb_gap, sb_y, sb_w, sb_h)); // Open the listener. If it fails (port in use), put the error // into the status label; user can hit Cancel. @@ -209,6 +178,7 @@ namespace enigma { namespace gui { lbl_status->set_text(_("Could not open listening port.")); } + levelwidget->syncFromIndexMgr(); update_level_label(); update_status(); } @@ -237,13 +207,10 @@ namespace enigma { namespace gui { ind->getName().c_str())); std::string suffix = current_level_is_network() ? _(" [network]") - : _(" [single-player]"); + : _(" [single-player — may not work]"); lbl_level->set_text(ecl::strf(_("Level: #%d - %s"), pos + 1, title.c_str()) + suffix); } - static_cast(but_only_network) - ->set_text(only_network_levels ? _("Net only: on") - : _("Net only: off")); } void HostLobbyMenu::update_status() { @@ -307,47 +274,16 @@ namespace enigma { namespace gui { : lev::Index::previousGroupIndex(); if (target && target != cur) lev::Index::setCurrentIndex(target->getName()); - // If filter is on, snap to first network-mode level in the - // new pack. - if (only_network_levels) { - lev::Index *ind = lev::Index::getCurrentIndex(); - int nxt = find_network_level(ind, 0, +1); - if (nxt >= 0) - ind->setCurrentPosition(nxt); - } + levelwidget->syncFromIndexMgr(); update_level_label(); invalidate_all(); return; } - if (w == but_prev_level || w == but_next_level) { - lev::Index *ind = lev::Index::getCurrentIndex(); - if (!ind || ind->size() == 0) return; - int dir = (w == but_next_level) ? +1 : -1; - int sz = ind->size(); - int pos = ind->getCurrentPosition(); - int start = pos + dir; - if (start < 0) start = 0; - if (start >= sz) start = sz - 1; - int found; - if (only_network_levels) { - found = find_network_level(ind, start, dir); - } else { - found = (start >= 0 && start < sz) ? start : -1; - } - if (found >= 0) { - ind->setCurrentPosition(found); - update_level_label(); - invalidate_all(); - } - return; - } - if (w == but_only_network) { - only_network_levels = !only_network_levels; - if (only_network_levels && !current_level_is_network()) { - lev::Index *ind = lev::Index::getCurrentIndex(); - int nxt = find_network_level(ind, 0, +1); - if (nxt >= 0) ind->setCurrentPosition(nxt); - } + if (w == levelwidget) { + // LevelWidget fired a "selected" action (click or Enter). + // In the regular menu this launches the game; here we just + // adopt the chosen level and update the label. The actual + // game start waits for the Start Game button. update_level_label(); invalidate_all(); return; @@ -365,13 +301,19 @@ namespace enigma { namespace gui { f->render(gc, (vminfo->width - tw) / 2, 40, title.c_str()); } - void HostLobbyMenu::tick(double /*dtime*/) { + void HostLobbyMenu::tick(double dtime) { if (game_started) return; + levelwidget->tick(dtime); netgame::ServiceHostLobby(); static double accu = 0; accu += 0.01; if (accu >= 0.2) { accu = 0; + // Cursor on the LevelWidget changes via mouse motion or + // arrow keys, neither of which routes through on_action. + // Refresh the label so the selection display stays + // in sync. + update_level_label(); update_status(); invalidate_all(); } diff --git a/src/gui/MainMenu.hh b/src/gui/MainMenu.hh index c0f1af482..62dbcdc3f 100644 --- a/src/gui/MainMenu.hh +++ b/src/gui/MainMenu.hh @@ -111,6 +111,8 @@ namespace enigma { namespace gui { /* -------------------- HostLobbyMenu -------------------- */ + class LevelWidget; + class HostLobbyMenu : public gui::Menu { public: HostLobbyMenu(); @@ -133,13 +135,10 @@ namespace enigma { namespace gui { gui::Label *lbl_failed; gui::Widget *but_prev_pack; gui::Widget *but_next_pack; - gui::Widget *but_prev_level; - gui::Widget *but_next_level; - gui::Widget *but_only_network; gui::Widget *but_start; gui::Widget *but_cancel; + LevelWidget *levelwidget; - bool only_network_levels; bool game_started; }; diff --git a/src/gui/TextField.cc b/src/gui/TextField.cc index c8706be4f..fc5921567 100644 --- a/src/gui/TextField.cc +++ b/src/gui/TextField.cc @@ -142,7 +142,7 @@ bool TextField::on_event(const SDL_Event &e) { //strcat(text, e.text.text); std::string newText(e.text.text); int totalLength = textPreCursor.length() + newText.length() + textPostCursor.length(); - if ( (maxChars >= 0 && totalLength >= maxChars) + if ( (maxChars >= 0 && totalLength > maxChars) || (newText.length() == 1 && invalidChars.find((char)(newText[0])) != std::string::npos)) { // string too long or invalid char sound::EmitSoundEvent ("menustop"); From d94c6d438e5d76f15e0b7243ca8f8ed61de5ae72 Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 15:32:05 +0200 Subject: [PATCH 11/13] launch on level click in the host lobby; remember last join host The host lobby no longer has a Start Game button. Clicking (or pressing Enter on) a level in the LevelWidget arms that level; the game starts as soon as a client has authenticated. If a client is already waiting, the click launches immediately. Status text shows which level is armed while waiting. Re-clicking before the client arrives replaces the armed level. The join dialog now defaults the host field to the last value that produced a successful game (persisted as NetGameJoinHost in app state), falling back to "localhost" the first time. The host status line now prints "Listening on 0.0.0.0:N" to make it explicit that the server already accepts connections on every interface. --- src/gui/MainMenu.cc | 96 ++++++++++++++++++++++++++------------------- src/gui/MainMenu.hh | 4 +- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/src/gui/MainMenu.cc b/src/gui/MainMenu.cc index 46251970f..33e57b62b 100644 --- a/src/gui/MainMenu.cc +++ b/src/gui/MainMenu.cc @@ -116,7 +116,9 @@ namespace enigma { namespace gui { lbl_failed(new Label("", HALIGN_LEFT)), levelwidget(new LevelWidget(/*withScoreIcons=*/true, /*withEditBorder=*/false)), - game_started(false) + game_started(false), + armed(false), + armed_pos(0) { const VMInfo *vminfo = video_engine->GetInfo(); int w = vminfo->width; @@ -162,14 +164,9 @@ namespace enigma { namespace gui { int sb_w = 140; int sb_h = 36; - int sb_gap = 20; int sb_y = h - sb_h - margin; - int sb_total = sb_w * 2 + sb_gap; - int sb_x = (w - sb_total) / 2; - but_start = new StaticTextButton(N_("Start Game"), this); but_cancel = new StaticTextButton(N_("Cancel"), this); - this->add(but_start, Rect(sb_x, sb_y, sb_w, sb_h)); - this->add(but_cancel, Rect(sb_x + sb_w + sb_gap, sb_y, sb_w, sb_h)); + this->add(but_cancel, Rect((w - sb_w) / 2, sb_y, sb_w, sb_h)); // Open the listener. If it fails (port in use), put the error // into the status label; user can hit Cancel. @@ -216,14 +213,32 @@ namespace enigma { namespace gui { void HostLobbyMenu::update_status() { lbl_code->set_text(ecl::strf(_("Access code: %s"), netgame::LobbyCode().c_str())); - lbl_port->set_text(ecl::strf(_("Listening on port %d"), + lbl_port->set_text(ecl::strf(_("Listening on 0.0.0.0:%d"), netgame::LobbyPort())); - if (netgame::LobbyHasReadyClient()) { - lbl_status->set_text(_("Client connected — press Start Game.")); - } else if (netgame::LobbyHasPendingClient()) { + bool ready = netgame::LobbyHasReadyClient(); + bool pending = netgame::LobbyHasPendingClient(); + std::string armed_title; + if (armed) { + if (lev::Index *ai = lev::Index::findIndex(armed_pack)) { + if (armed_pos >= 0 && armed_pos < ai->size()) { + if (lev::Proxy *p = ai->getProxy(armed_pos)) + armed_title = ecl::strf("#%d - %s", + armed_pos + 1, p->getTitle().c_str()); + } + } + } + if (ready && armed) { + lbl_status->set_text(_("Starting...")); + } else if (ready) { + lbl_status->set_text(_("Client connected — click a level to play.")); + } else if (armed) { + lbl_status->set_text(ecl::strf( + _("Will play %s — waiting for client..."), + armed_title.c_str())); + } else if (pending) { lbl_status->set_text(_("Client connecting — waiting for code...")); } else if (netgame::LobbyPort() != 0) { - lbl_status->set_text(_("Waiting for client...")); + lbl_status->set_text(_("Waiting for client; click a level when ready.")); } int n = netgame::LobbyFailedAttempts(); if (n == 0) { @@ -244,28 +259,6 @@ namespace enigma { namespace gui { Menu::quit(); return; } - if (w == but_start) { - if (!netgame::LobbyHasReadyClient()) { - lbl_status->set_text(_("No client connected yet.")); - invalidate_all(); - return; - } - lev::Index *ind = lev::Index::getCurrentIndex(); - if (!ind || ind->size() == 0) { - lbl_status->set_text(_("No level selected.")); - invalidate_all(); - return; - } - game_started = true; - std::string pack = ind->getName(); - int pos = ind->getCurrentPosition(); - // StartHostedGame takes over the listener and runs the - // whole game synchronously. When it returns, the lobby is - // already closed. - netgame::StartHostedGame(pack, pos); - Menu::quit(); - return; - } if (w == but_prev_pack || w == but_next_pack) { lev::Index *cur = lev::Index::getCurrentIndex(); if (!cur) return; @@ -280,10 +273,16 @@ namespace enigma { namespace gui { return; } if (w == levelwidget) { - // LevelWidget fired a "selected" action (click or Enter). - // In the regular menu this launches the game; here we just - // adopt the chosen level and update the label. The actual - // game start waits for the Start Game button. + // The user clicked (or hit Enter on) a level: arm it. The + // game launches as soon as a client has authenticated, or + // immediately if one already has. Re-clicking before the + // client arrives replaces the armed level with the new one. + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind && ind->size() > 0) { + armed = true; + armed_pack = ind->getName(); + armed_pos = ind->getCurrentPosition(); + } update_level_label(); invalidate_all(); return; @@ -305,6 +304,18 @@ namespace enigma { namespace gui { if (game_started) return; levelwidget->tick(dtime); netgame::ServiceHostLobby(); + + // Launch the game once both ingredients are present: an armed + // level (user has clicked or pressed Enter) and an + // authenticated client. StartHostedGame runs the whole game + // synchronously and returns when it ends. + if (armed && netgame::LobbyHasReadyClient()) { + game_started = true; + netgame::StartHostedGame(armed_pack, armed_pos); + Menu::quit(); + return; + } + static double accu = 0; accu += 0.01; if (accu >= 0.2) { @@ -322,7 +333,10 @@ namespace enigma { namespace gui { /* -------------------- JoinLobbyMenu -------------------- */ JoinLobbyMenu::JoinLobbyMenu() - : tf_host(new TextField("localhost")), + : tf_host(new TextField( + app.state->getString("NetGameJoinHost").empty() + ? std::string("localhost") + : app.state->getString("NetGameJoinHost"))), tf_port(new TextField("12345")), tf_code(new TextField("")), lbl_status(new Label("", HALIGN_LEFT)) @@ -400,7 +414,9 @@ namespace enigma { namespace gui { netgame::Join(host, port, code); std::string err = netgame::LastJoinError(); if (err.empty()) { - // Game ran to completion; close this menu. + // Game ran to completion; remember the host for next + // time and close this menu. + app.state->setProperty("NetGameJoinHost", host); Menu::quit(); } else { lbl_status->set_text(err); diff --git a/src/gui/MainMenu.hh b/src/gui/MainMenu.hh index 62dbcdc3f..8512efd08 100644 --- a/src/gui/MainMenu.hh +++ b/src/gui/MainMenu.hh @@ -135,11 +135,13 @@ namespace enigma { namespace gui { gui::Label *lbl_failed; gui::Widget *but_prev_pack; gui::Widget *but_next_pack; - gui::Widget *but_start; gui::Widget *but_cancel; LevelWidget *levelwidget; bool game_started; + bool armed; // user has picked a level and wants to play + std::string armed_pack; + int armed_pos; }; /* -------------------- JoinLobbyMenu -------------------- */ From 2c8839e3af1f9d398a2812d15926151eaa5c2eac Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 15:40:08 +0200 Subject: [PATCH 12/13] skip non-network levels on advance; broadcast both inventories at load Two LAN gameplay polish fixes: Advance-after-win used Index::advanceLevel which steps to the very next slot in the pack regardless of mode. Hitting a single-player-only level inside a network session would silently turn off two-player mode for everyone. Msg_AdvanceLevel now loops past non-network levels when netgame::IsActive(), falling back to abort if the pack has no more network-mode levels. player::NewGame put two extralifes into each player's inventory but never notified the EventSink for the non-current player, so the remote saw an empty inventory on level start and ended up with zero extra lives. NewGame now calls RedrawInventory for both inventories; NotifyInventoryChanged is then fired for each, and the remote's status bar matches the host's setup. --- src/client.cc | 17 ++++++++++++++++- src/player.cc | 8 ++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/client.cc b/src/client.cc index 64af0a556..fd2c9ae83 100644 --- a/src/client.cc +++ b/src/client.cc @@ -1102,7 +1102,22 @@ void Msg_AdvanceLevel(lev::LevelAdvanceMode mode) { // log last played level lev::PersistentIndex::addCurrentToHistory(); - if (level_index->advanceLevel(mode)) { + bool ok = level_index->advanceLevel(mode); + // In a LAN session, keep advancing until we hit a level that the + // pack marks as network-playable; landing on a single-player-only + // level mid-session would silently switch off two-player mode. + while (ok && netgame::IsActive()) { + lev::Proxy *p = level_index->getCurrent(); + bool is_network = false; + if (p) { + try { p->loadMetadata(true); } catch (...) {} + is_network = p->hasNetworkMode(); + } + if (is_network) break; + ok = level_index->advanceLevel(mode); + } + + if (ok) { // The remote doesn't run levels itself — the host streams the // new world state. We just advance the index for the caption. if (!netgame::IsClient()) diff --git a/src/player.cc b/src/player.cc index 4461184c9..8a3efd4e7 100644 --- a/src/player.cc +++ b/src/player.cc @@ -170,6 +170,14 @@ void player::NewGame() { inv->add_item(MakeItem("it_extralife")); } + // Broadcast every player's initial inventory. The local status bar + // for the current player is refreshed by LevelLoaded below; the + // explicit notify here makes sure a LAN peer that holds the *other* + // player also receives their starting items (extralives in + // particular). + for (int i = 0; i < nplayers; ++i) + RedrawInventory(GetInventory(i)); + unassignedActors.clear(); leveldat.reset(); } From 0c8888233085c65a76831ba7a87c35bc08ce396f Mon Sep 17 00:00:00 2001 From: Thomas Neumann Date: Thu, 14 May 2026 15:51:29 +0200 Subject: [PATCH 13/13] seed from random_device; bound failed-attempt counter Two defensive cleanups in the netgame code: The seed broadcast in SV_HELLO was Uint32(SDL_GetTicks()) ^ Uint32(uintptr_t(peer)). XORing with a heap pointer leaked the lower 32 bits of the host's heap address layout to the authenticated peer (useful only as an ASLR-weakening primitive if combined with another bug, but easy to avoid). Use std::random_device directly. failed_attempts was a plain int++ in record_failed_attempt. Cap it at INT_MAX so a long-running session can't get to undefined-behaviour overflow. --- src/netgame.cc | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/netgame.cc b/src/netgame.cc index 2fef46fa4..6266c9402 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -39,6 +39,7 @@ #include "enet_ver.hh" #include "SDL.h" +#include #include #include #include @@ -754,7 +755,8 @@ void send_and_disconnect(ENetPeer *peer, const ecl::Buffer &buf) { } void record_failed_attempt(ENetPeer *peer, const std::string &reason) { - s_lobby.failed_attempts++; + if (s_lobby.failed_attempts < INT_MAX) + s_lobby.failed_attempts++; s_lobby.last_fail_reason = ecl::strf("%s from %s", reason.c_str(), @@ -978,7 +980,11 @@ void netgame::StartHostedGame(const std::string &level_pack, int level_pos) { ind->getName().c_str(), level_pos + 1, proxy->getTitle().c_str(), failed_during_lobby); - Uint32 seed = Uint32(SDL_GetTicks()) ^ Uint32(uintptr_t(peer)); + // The seed travels to the remote in SV_HELLO; mixing in a heap + // pointer would leak ASLR information. Use std::random_device so + // there's nothing the remote can learn about the host's address + // space. + Uint32 seed = std::random_device{}(); const int remote_player = 1; s_in_session = true;