forked from mirrors/0ad
68d8604c51
When observing a player with camera follow enabled, the camera would jump to newly trained units as they executed their rally point commands, which were just rendering the "Follow Player" feature frustrating.
628 lines
16 KiB
JavaScript
628 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()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 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 = () => { 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();
|
|
|
|
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();
|
|
}
|