mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-06-16 05:13:58 -07:00
When the connection fails, it wasn't possible to close the progress window. Now `PollNetworkClient` also resolves the previous promise.
633 lines
16 KiB
JavaScript
633 lines
16 KiB
JavaScript
/**
|
|
* All tutorial messages received so far.
|
|
*/
|
|
var g_TutorialMessages = [];
|
|
|
|
/**
|
|
* GUI tags applied to the most recent tutorial message.
|
|
*/
|
|
var g_TutorialNewMessageTags = { "color": "255 226 149" };
|
|
|
|
/**
|
|
* The number of seconds we monitor to rate limit flares.
|
|
*/
|
|
var g_FlareRateLimitScope = 6;
|
|
|
|
/**
|
|
* The maximum allowed number of flares within g_FlareRateLimitScope seconds.
|
|
* This should be a bit larger than the number of flares that can be sent in theory by using the GUI.
|
|
*/
|
|
var g_FlareRateLimitMaximumFlares = 16;
|
|
|
|
/**
|
|
* Contains the arrival timestamps the flares of the last g_FlareRateLimitScope seconds.
|
|
*/
|
|
var g_FlareRateLimitLastTimes = [];
|
|
|
|
/**
|
|
* These handlers are called everytime a client joins or disconnects.
|
|
*/
|
|
var g_PlayerAssignmentsChangeHandlers = new Set();
|
|
|
|
/**
|
|
* These handlers are called when the ceasefire time has run out.
|
|
*/
|
|
var g_CeasefireEndedHandlers = new Set();
|
|
|
|
/**
|
|
* These handlers are fired when the match is networked and
|
|
* the current client established the connection, authenticated,
|
|
* finished the loading screen, starts or finished synchronizing after a rejoin.
|
|
* The messages are constructed in NetClient.cpp.
|
|
*/
|
|
var g_NetworkStatusChangeHandlers = new Set();
|
|
|
|
/**
|
|
* These handlers are triggered whenever a client finishes the loading screen.
|
|
*/
|
|
var g_ClientsLoadingHandlers = new Set();
|
|
|
|
/**
|
|
* These handlers are fired if the server informed the players that the networked game is out of sync.
|
|
*/
|
|
var g_NetworkOutOfSyncHandlers = new Set();
|
|
|
|
/**
|
|
* Handle all netmessage types that can occur.
|
|
*/
|
|
var g_NetMessageTypes = {
|
|
"netstatus": msg =>
|
|
{
|
|
handleNetStatusMessage(msg);
|
|
},
|
|
"netwarn": msg =>
|
|
{
|
|
addNetworkWarning(msg);
|
|
},
|
|
"out-of-sync": msg =>
|
|
{
|
|
for (const handler of g_NetworkOutOfSyncHandlers)
|
|
handler(msg);
|
|
},
|
|
"players": msg =>
|
|
{
|
|
handlePlayerAssignmentsMessage(msg);
|
|
},
|
|
"paused": msg =>
|
|
{
|
|
g_PauseControl.setClientPauseState(msg.guid, msg.pause);
|
|
},
|
|
"clients-loading": msg =>
|
|
{
|
|
for (const handler of g_ClientsLoadingHandlers)
|
|
handler(msg.guids);
|
|
},
|
|
"rejoined": msg =>
|
|
{
|
|
addChatMessage({
|
|
"type": "rejoined",
|
|
"guid": msg.guid
|
|
});
|
|
},
|
|
"kicked": msg =>
|
|
{
|
|
addChatMessage({
|
|
"type": "kicked",
|
|
"username": msg.username,
|
|
"banned": msg.banned
|
|
});
|
|
},
|
|
"chat": msg =>
|
|
{
|
|
addChatMessage({
|
|
"type": "message",
|
|
"guid": msg.guid,
|
|
"text": msg.text
|
|
});
|
|
},
|
|
"flare": msg =>
|
|
{
|
|
handleFlare(msg);
|
|
},
|
|
"gamesetup": msg => {}, // Needed for autostart
|
|
"start": msg => {}
|
|
};
|
|
|
|
var g_PlayerStateMessages = {
|
|
"won": translate("You have won!"),
|
|
"defeated": translate("You have been defeated!")
|
|
};
|
|
|
|
var g_LastAttack;
|
|
|
|
/**
|
|
* Defines how the GUI reacts to notifications that are sent by the simulation.
|
|
* Don't open new pages (message boxes) here! Otherwise further notifications
|
|
* handled in the same turn can't access the GUI objects anymore.
|
|
*/
|
|
var g_NotificationsTypes =
|
|
{
|
|
"aichat": function(notification, player)
|
|
{
|
|
const message = {
|
|
"type": "message",
|
|
"text": notification.message,
|
|
"guid": findGuidForPlayerID(player) || -1,
|
|
"player": player,
|
|
"translate": true
|
|
};
|
|
|
|
if (notification.translateParameters)
|
|
{
|
|
message.translateParameters = notification.translateParameters;
|
|
message.parameters = notification.parameters;
|
|
colorizePlayernameParameters(notification.parameters);
|
|
}
|
|
|
|
addChatMessage(message);
|
|
},
|
|
"defeat": function(notification, player)
|
|
{
|
|
playersFinished(notification.allies, notification.message, false);
|
|
},
|
|
"won": function(notification, player)
|
|
{
|
|
playersFinished(notification.allies, notification.message, true);
|
|
},
|
|
"diplomacy": function(notification, player)
|
|
{
|
|
updatePlayerData();
|
|
g_DiplomacyColors.onDiplomacyChange();
|
|
|
|
addChatMessage({
|
|
"type": "diplomacy",
|
|
"sourcePlayer": player,
|
|
"targetPlayer": notification.targetPlayer,
|
|
"status": notification.status
|
|
});
|
|
},
|
|
"ceasefire-ended": function(notification, player)
|
|
{
|
|
updatePlayerData();
|
|
for (const handler of g_CeasefireEndedHandlers)
|
|
handler();
|
|
},
|
|
"tutorial": function(notification, player)
|
|
{
|
|
updateTutorial(notification);
|
|
},
|
|
"tribute": function(notification, player)
|
|
{
|
|
addChatMessage({
|
|
"type": "tribute",
|
|
"sourcePlayer": notification.donator,
|
|
"targetPlayer": player,
|
|
"amounts": notification.amounts
|
|
});
|
|
},
|
|
"barter": function(notification, player)
|
|
{
|
|
addChatMessage({
|
|
"type": "barter",
|
|
"player": player,
|
|
"amountGiven": notification.amountGiven,
|
|
"amountGained": notification.amountGained,
|
|
"resourceGiven": notification.resourceGiven,
|
|
"resourceGained": notification.resourceGained
|
|
});
|
|
},
|
|
"spy-response": function(notification, player)
|
|
{
|
|
g_DiplomacyDialog.onSpyResponse(notification, player);
|
|
|
|
if (notification.entity && g_ViewedPlayer == player && (!g_IsObserver || g_FollowPlayer))
|
|
{
|
|
g_DiplomacyDialog.close();
|
|
setCameraFollow(notification.entity);
|
|
}
|
|
},
|
|
"attack": function(notification, player)
|
|
{
|
|
if (player != g_ViewedPlayer)
|
|
return;
|
|
|
|
// Focus camera on attacks
|
|
if (g_FollowPlayer)
|
|
{
|
|
setCameraFollow(notification.target);
|
|
|
|
g_Selection.reset();
|
|
if (notification.target)
|
|
g_Selection.addList([notification.target]);
|
|
}
|
|
|
|
g_LastAttack = { "target": notification.target, "position": notification.position };
|
|
|
|
if (Engine.ConfigDB_GetValue("user", "gui.session.notifications.attack") !== "true")
|
|
return;
|
|
|
|
addChatMessage({
|
|
"type": "attack",
|
|
"player": player,
|
|
"attacker": notification.attacker,
|
|
"target": notification.target,
|
|
"position": notification.position,
|
|
"targetIsDomesticAnimal": notification.targetIsDomesticAnimal
|
|
});
|
|
},
|
|
"phase": function(notification, player)
|
|
{
|
|
addChatMessage({
|
|
"type": "phase",
|
|
"player": player,
|
|
"phaseName": notification.phaseName,
|
|
"phaseState": notification.phaseState
|
|
});
|
|
},
|
|
"dialog": function(notification, player)
|
|
{
|
|
if (player == Engine.GetPlayerID())
|
|
openDialog(notification.dialogName, notification.data, player);
|
|
},
|
|
"playercommand": function(notification, player)
|
|
{
|
|
// For observers, focus the camera on units commanded by the selected player
|
|
if (!g_FollowPlayer || player != g_ViewedPlayer)
|
|
return;
|
|
|
|
const cmd = notification.cmd;
|
|
|
|
// Ignore commands executed because of units following rally points
|
|
if (cmd.fromRallyPoint)
|
|
return;
|
|
|
|
const entState = cmd.entities && cmd.entities[0] && GetEntityState(cmd.entities[0]);
|
|
|
|
// Focus the structure to build.
|
|
if (cmd.type == "repair")
|
|
{
|
|
const targetState = GetEntityState(cmd.target);
|
|
if (targetState)
|
|
Engine.CameraMoveTo(targetState.position.x, targetState.position.z);
|
|
}
|
|
else if (cmd.type == "delete-entities" && notification.position)
|
|
Engine.CameraMoveTo(notification.position.x, notification.position.y);
|
|
// Focus commanded entities, but don't lose previous focus when training units
|
|
else if (cmd.type != "train" && cmd.type != "research" && entState)
|
|
setCameraFollow(cmd.entities[0]);
|
|
|
|
if (["walk", "attack-walk", "patrol"].indexOf(cmd.type) != -1)
|
|
DrawTargetMarker(cmd);
|
|
|
|
// Select units affected by that command
|
|
let selection = [];
|
|
if (cmd.entities)
|
|
selection = cmd.entities;
|
|
if (cmd.target)
|
|
selection.push(cmd.target);
|
|
|
|
// Allow gaia in selection when gathering
|
|
g_Selection.reset();
|
|
g_Selection.addList(selection, false, cmd.type == "gather");
|
|
},
|
|
"play-tracks": function(notification, player)
|
|
{
|
|
if (notification.lock)
|
|
{
|
|
global.music.storeTracks(notification.tracks.map(track => ({ "Type": "custom", "File": track })));
|
|
global.music.setState(global.music.states.CUSTOM);
|
|
}
|
|
|
|
global.music.setLocked(notification.lock);
|
|
}
|
|
};
|
|
|
|
function registerPlayerAssignmentsChangeHandler(handler)
|
|
{
|
|
g_PlayerAssignmentsChangeHandlers.add(handler);
|
|
}
|
|
|
|
function registerCeasefireEndedHandler(handler)
|
|
{
|
|
g_CeasefireEndedHandlers.add(handler);
|
|
}
|
|
|
|
function registerNetworkOutOfSyncHandler(handler)
|
|
{
|
|
g_NetworkOutOfSyncHandlers.add(handler);
|
|
}
|
|
|
|
function registerNetworkStatusChangeHandler(handler)
|
|
{
|
|
g_NetworkStatusChangeHandlers.add(handler);
|
|
}
|
|
|
|
function registerClientsLoadingHandler(handler)
|
|
{
|
|
g_ClientsLoadingHandlers.add(handler);
|
|
}
|
|
|
|
function findGuidForPlayerID(playerID)
|
|
{
|
|
return Object.keys(g_PlayerAssignments).find(guid => g_PlayerAssignments[guid].player == playerID);
|
|
}
|
|
|
|
/**
|
|
* Processes all pending notifications sent from the GUIInterface simulation component.
|
|
*/
|
|
function handleNotifications(closePageCallback)
|
|
{
|
|
for (const notification of Engine.GuiInterfaceCall("GetNotifications"))
|
|
{
|
|
if (!notification.players || !notification.type || !g_NotificationsTypes[notification.type])
|
|
{
|
|
error("Invalid GUI notification: " + uneval(notification));
|
|
continue;
|
|
}
|
|
|
|
for (const player of notification.players)
|
|
g_NotificationsTypes[notification.type](notification, player, closePageCallback);
|
|
}
|
|
}
|
|
|
|
function focusAttack(attack)
|
|
{
|
|
if (!attack)
|
|
return;
|
|
|
|
const entState = attack.target && GetEntityState(attack.target);
|
|
if (entState && hasClass(entState, "Unit"))
|
|
setCameraFollow(attack.target);
|
|
else
|
|
{
|
|
const position = attack.position;
|
|
Engine.SetCameraTarget(position.x, position.y, position.z);
|
|
}
|
|
}
|
|
|
|
function toggleTutorial()
|
|
{
|
|
const tutorialPanel = Engine.GetGUIObjectByName("tutorialPanel");
|
|
tutorialPanel.hidden = !tutorialPanel.hidden || !Engine.GetGUIObjectByName("tutorialText").caption;
|
|
}
|
|
|
|
/**
|
|
* Updates the tutorial panel when a new goal.
|
|
*/
|
|
function updateTutorial(notification, closePageCallback)
|
|
{
|
|
// Show the tutorial panel if not yet done
|
|
Engine.GetGUIObjectByName("tutorialPanel").hidden = false;
|
|
|
|
if (notification.warning)
|
|
{
|
|
Engine.GetGUIObjectByName("tutorialWarning").caption = coloredText(translate(notification.warning), "orange");
|
|
return;
|
|
}
|
|
|
|
const notificationText =
|
|
notification.instructions.reduce((instructions, item) =>
|
|
instructions + (typeof item === "string" ? translate(item) : colorizeHotkey(translate(item.text), item.hotkey)),
|
|
"");
|
|
|
|
Engine.GetGUIObjectByName("tutorialText").caption = g_TutorialMessages.concat(setStringTags(notificationText, g_TutorialNewMessageTags)).join("\n");
|
|
g_TutorialMessages.push(notificationText);
|
|
|
|
if (notification.readyButton)
|
|
{
|
|
Engine.GetGUIObjectByName("tutorialReady").hidden = false;
|
|
if (notification.leave)
|
|
{
|
|
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Click to quit this tutorial.");
|
|
Engine.GetGUIObjectByName("tutorialReady").caption = translate("Quit");
|
|
|
|
Engine.GetGUIObjectByName("tutorialReady").onPress = () =>
|
|
{
|
|
closePageCallback({ [Engine.openRequest]: endGame(true) });
|
|
};
|
|
}
|
|
else
|
|
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Click when ready.");
|
|
}
|
|
else
|
|
{
|
|
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Follow the instructions.");
|
|
Engine.GetGUIObjectByName("tutorialReady").hidden = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process every CNetMessage (see NetMessage.h, NetMessages.h) sent by the CNetServer.
|
|
* Saves the received object to mainlog.html.
|
|
*/
|
|
async function handleNetMessages()
|
|
{
|
|
while (true)
|
|
{
|
|
const msg = await Engine.PollNetworkClient();
|
|
if (!msg)
|
|
return;
|
|
|
|
log("Net message: " + uneval(msg));
|
|
|
|
if (g_NetMessageTypes[msg.type])
|
|
g_NetMessageTypes[msg.type](msg);
|
|
else
|
|
error("Unrecognized net message type '" + msg.type + "'");
|
|
}
|
|
}
|
|
|
|
function handleNetStatusMessage(message)
|
|
{
|
|
if (g_Disconnected)
|
|
return;
|
|
|
|
g_IsNetworkedActive = message.status == "active";
|
|
|
|
if (message.status == "disconnected")
|
|
{
|
|
g_Disconnected = true;
|
|
closeOpenDialogs();
|
|
}
|
|
|
|
for (const handler of g_NetworkStatusChangeHandlers)
|
|
handler(message);
|
|
}
|
|
|
|
function handlePlayerAssignmentsMessage(message)
|
|
{
|
|
for (const guid in g_PlayerAssignments)
|
|
if (!message.newAssignments[guid])
|
|
onClientLeave(guid);
|
|
|
|
const joins = Object.keys(message.newAssignments).filter(guid => !g_PlayerAssignments[guid]);
|
|
|
|
g_PlayerAssignments = message.newAssignments;
|
|
|
|
joins.forEach(guid =>
|
|
{
|
|
onClientJoin(guid);
|
|
});
|
|
|
|
for (const handler of g_PlayerAssignmentsChangeHandlers)
|
|
handler();
|
|
|
|
// TODO: use subscription instead
|
|
updateGUIObjects();
|
|
}
|
|
|
|
function onClientJoin(guid)
|
|
{
|
|
const playerID = g_PlayerAssignments[guid].player;
|
|
|
|
if (g_Players[playerID])
|
|
{
|
|
g_Players[playerID].guid = guid;
|
|
g_Players[playerID].name = g_PlayerAssignments[guid].name;
|
|
g_Players[playerID].offline = false;
|
|
}
|
|
|
|
addChatMessage({
|
|
"type": "connect",
|
|
"guid": guid
|
|
});
|
|
}
|
|
|
|
function onClientLeave(guid)
|
|
{
|
|
g_PauseControl.setClientPauseState(guid, false);
|
|
|
|
for (const id in g_Players)
|
|
if (g_Players[id].guid == guid)
|
|
g_Players[id].offline = true;
|
|
|
|
addChatMessage({
|
|
"type": "disconnect",
|
|
"guid": guid
|
|
});
|
|
}
|
|
|
|
function addChatMessage(msg)
|
|
{
|
|
g_Chat.ChatMessageHandler.handleMessage(msg);
|
|
}
|
|
|
|
function clearChatMessages()
|
|
{
|
|
g_Chat.ChatOverlay.clearChatMessages();
|
|
}
|
|
|
|
function handleFlare(data)
|
|
{
|
|
const playerID = g_PlayerAssignments[data.guid].player;
|
|
const shouldSeeFlare = g_IsObserver || g_Players[playerID]?.isMutualAlly[Engine.GetPlayerID()];
|
|
|
|
// Don't display for the player that sent the flare because they will see it immediately.
|
|
if (!shouldSeeFlare || data.guid == Engine.GetPlayerGUID())
|
|
return;
|
|
|
|
const now = Date.now();
|
|
if (g_FlareRateLimitLastTimes.length)
|
|
{
|
|
g_FlareRateLimitLastTimes = g_FlareRateLimitLastTimes.filter(t => now - t < g_FlareRateLimitScope * 1000);
|
|
if (g_FlareRateLimitLastTimes.length >= g_FlareRateLimitMaximumFlares)
|
|
{
|
|
warn("Received too many flares. Dropping a flare request by '" + g_PlayerAssignments[data.guid].name + "'.");
|
|
return;
|
|
}
|
|
}
|
|
g_FlareRateLimitLastTimes.push(now);
|
|
|
|
renderAndPlayFlare(data.position, data.guid);
|
|
}
|
|
|
|
/**
|
|
* This function is used for AIs, whose names don't exist in g_PlayerAssignments.
|
|
*/
|
|
function colorizePlayernameByID(playerID)
|
|
{
|
|
const username = g_Players[playerID] && escapeText(g_Players[playerID].name);
|
|
return colorizePlayernameHelper(username, playerID);
|
|
}
|
|
|
|
function colorizePlayernameByGUID(guid)
|
|
{
|
|
const username = g_PlayerAssignments[guid] ? g_PlayerAssignments[guid].name : "";
|
|
const playerID = g_PlayerAssignments[guid] ? g_PlayerAssignments[guid].player : -1;
|
|
return colorizePlayernameHelper(username, playerID);
|
|
}
|
|
|
|
function colorizePlayernameHelper(username, playerID)
|
|
{
|
|
const playerColor = playerID > -1 ? g_DiplomacyColors.getPlayerColor(playerID) : "white";
|
|
return coloredText(username || translate("Unknown Player"), playerColor);
|
|
}
|
|
|
|
/**
|
|
* Insert the colorized playername to chat messages sent by the AI and time notifications.
|
|
*/
|
|
function colorizePlayernameParameters(parameters)
|
|
{
|
|
for (const param in parameters)
|
|
if (param.startsWith("_player_"))
|
|
parameters[param] = colorizePlayernameByID(parameters[param]);
|
|
}
|
|
|
|
/**
|
|
* Custom dialog response handling, usable by trigger maps.
|
|
*/
|
|
function sendDialogAnswer(guiObject, dialogName)
|
|
{
|
|
Engine.GetGUIObjectByName(dialogName + "-dialog").hidden = true;
|
|
|
|
Engine.PostNetworkCommand({
|
|
"type": "dialog-answer",
|
|
"dialog": dialogName,
|
|
"answer": guiObject.name.split("-").pop(),
|
|
});
|
|
|
|
resumeGame();
|
|
}
|
|
|
|
/**
|
|
* Custom dialog opening, usable by trigger maps.
|
|
*/
|
|
function openDialog(dialogName, data, player)
|
|
{
|
|
const dialog = Engine.GetGUIObjectByName(dialogName + "-dialog");
|
|
if (!dialog)
|
|
{
|
|
warn("messages.js: Unknown dialog with name " + dialogName);
|
|
return;
|
|
}
|
|
dialog.hidden = false;
|
|
|
|
for (const objName in data)
|
|
{
|
|
const obj = Engine.GetGUIObjectByName(dialogName + "-dialog-" + objName);
|
|
if (!obj)
|
|
{
|
|
warn("messages.js: Key '" + objName + "' not found in '" + dialogName + "' dialog.");
|
|
continue;
|
|
}
|
|
|
|
for (const key in data[objName])
|
|
{
|
|
const n = data[objName][key];
|
|
if (typeof n === "object" && n.message)
|
|
{
|
|
let message = n.message;
|
|
if (n.translateMessage)
|
|
message = translate(message);
|
|
const parameters = n.parameters || {};
|
|
if (n.translateParameters)
|
|
translateObjectKeys(parameters, n.translateParameters);
|
|
obj[key] = sprintf(message, parameters);
|
|
}
|
|
else
|
|
obj[key] = n;
|
|
}
|
|
}
|
|
|
|
g_PauseControl.implicitPause();
|
|
}
|