/*
* Glorious webAO
* made by sD, refactored by oldmud0 and Qubrick
* credits to aleks for original idea and source
*/
// Uses the Gify library:
// https://github.com/rfrench/gify
// The following comment is needed for ESLint:
/* global gify */
import background_arr from "./backgrounds.js";
import evidence_arr from "./evidence.js";
import Fingerprint from "./fingerprint.js";
import { EventEmitter } from "events";
const queryDict = {};
location.search.substr(1).split("&").forEach(function (item) {
queryDict[item.split("=")[0]] = item.split("=")[1];
});
/* Server magic */
const serverIP = queryDict.ip;
let mode = queryDict.mode;
const AO_HOST = queryDict.asset || "http://s3.wasabisys.com/webao/base/";
const MUSIC_HOST = AO_HOST + "sounds/music/";
const CHAR_SELECT_WIDTH = 8;
const UPDATE_INTERVAL = 60;
/**
* Toggles AO1-style loading using paginated music packets for mobile platforms.
* The old loading uses more smaller packets instead of a single big one,
* which caused problems on low-memory devices in the past.
*/
let oldLoading = false;
if (/webOS|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Kindle|Silk|Opera Mini/i.test(navigator.userAgent)) {
oldLoading = true;
}
let selectedEffect = 0;
let selectedMenu = 1;
let selectedShout = 0;
const fp = new Fingerprint({
canvas: true,
ie_activex: true,
screen_resolution: true
});
/** An emulated, semi-unique HDID that is generally safe for HDID bans. */
const hdid = fp.get();
console.log(`Your emulated HDID is ${hdid}`);
let lastICMessageTime = new Date(0);
class Client extends EventEmitter {
constructor(address) {
super();
this.serv = new WebSocket("ws://" + address);
this.serv.addEventListener("open", this.emit.bind(this, "open"));
this.serv.addEventListener("close", this.emit.bind(this, "close"));
this.serv.addEventListener("message", this.emit.bind(this, "message"));
this.serv.addEventListener("error", this.emit.bind(this, "error"));
this.on("open", this.onOpen.bind(this));
this.on("close", this.onClose.bind(this));
this.on("message", this.onMessage.bind(this));
this.on("error", this.onError.bind(this));
this.flip = false;
this.presentable = false;
this.hp = [0, 0];
this.playerID = 1;
this.charID = -1;
this.testimonyID = 0;
this.chars = [];
this.emotes = [];
this.evidences = [];
this.resources = {
"holdit": {
"src": "misc/holdit.gif",
"duration": 720
},
"objection": {
"src": "misc/objection.gif",
"duration": 720
},
"takethat": {
"src": "misc/takethat.gif",
"duration": 840
},
"witnesstestimony": {
"src": "misc/witnesstestimony.gif",
"duration": 1560,
"sfx": "sounds/general/sfx-testimony.wav"
},
"crossexamination": {
"src": "misc/crossexamination.gif",
"duration": 1600,
"sfx": "sounds/general/sfx-testimony2.wav"
}
};
this.selectedEmote = -1;
this.selectedEvidence = 0;
this.checkUpdater = null;
// Only used for RMC/`music` packets, not EM/SM/MC packets.
this.musicList = Object();
this.on("MS", this.handleMS.bind(this));
this.on("CT", this.handleCT.bind(this));
this.on("MC", this.handleMC.bind(this));
this.on("RMC", this.handleRMC.bind(this));
this.on("CI", this.handleCI.bind(this));
this.on("SC", this.handleSC.bind(this));
this.on("EI", this.handleEI.bind(this));
this.on("LE", this.handleLE.bind(this));
this.on("EM", this.handleEM.bind(this));
this.on("SM", this.handleSM.bind(this));
this.on("BD", this.handleBD.bind(this));
this.on("music", this.handlemusic.bind(this));
this.on("DONE", this.handleDONE.bind(this));
this.on("BN", this.handleBN.bind(this));
this.on("NBG", this.handleNBG.bind(this));
this.on("HP", this.handleHP.bind(this));
this.on("RT", this.handleRT.bind(this));
this.on("ZZ", this.handleZZ.bind(this));
this.on("ID", this.handleID.bind(this));
this.on("PN", this.handlePN.bind(this));
this.on("SI", this.handleSI.bind(this));
this.on("ARUP", this.handleARUP.bind(this));
this.on("CharsCheck", this.handleCharsCheck.bind(this));
this.on("PV", this.handlePV.bind(this));
this.on("CHECK", () => {});
this._lastTimeICReceived = new Date(0);
}
/**
* Gets the current player's character.
*/
get character() {
return this.chars[this.charID];
}
/**
* Gets the player's currently selected emote.
*/
get emote() {
return this.emotes[this.selectedEmote];
}
/**
* Gets the player's currently selected evidence if presentable.
*/
get evidence() {
return this.presentable ? this.selectedEvidence : 0;
}
/**
* Sends an out-of-character chat message.
* @param {string} message the message to send
*/
sendOOC(message) {
this.serv.send(`CT#${escapeChat(encodeChat(document.getElementById("OOC_name").value))}#${escapeChat(encodeChat(message))}#%`);
}
/**
* Sends an in-character chat message.
* @param {string} speaking who is speaking
* @param {string} name the name of the current character
* @param {string} silent whether or not it's silent
* @param {string} message the message to be sent
* @param {string} side the name of the side in the background
* @param {string} ssfxname the name of the sound effect
* @param {string} zoom whether or not to zoom
* @param {number} ssfxdelay the delay (in milliseconds) to play the sound effect
* @param {string} objection the number of the shout to play
* @param {string} evidence the filename of evidence to show
* @param {number} flip change to 1 to reverse sprite for position changes
* @param {string} flash screen flash effect
* @param {string} color text color
*/
sendIC(speaking, name, silent, message, side, ssfxname, zoom, ssfxdelay, objection, evidence, flip, flash, color) {
this.serv.send(
`MS#chat#${speaking}#${name}#${silent}` +
`#${escapeChat(encodeChat(message))}#${side}#${ssfxname}#${zoom}` +
`#${this.charID}#${ssfxdelay}#${selectedShout}#${evidence}#${flip}#${flash}#${color}#%`
);
}
/**
* Sends add evidence command.
* @param {string} evidence name
* @param {string} evidence description
* @param {string} evidence image filename
*/
sendPE(name, desc, img) {
this.serv.send(`PE#${escapeChat(encodeChat(name))}#${escapeChat(encodeChat(desc))}#${img}#%`);
}
/**
* Sends edit evidence command.
* @param {number} evidence id
* @param {string} evidence name
* @param {string} evidence description
* @param {string} evidence image filename
*/
sendEE(id, name, desc, img) {
this.serv.send(`EE#${id}#${escapeChat(encodeChat(name))}#${escapeChat(encodeChat(desc))}#${img}#%`);
}
/**
* Sends delete evidence command.
* @param {number} evidence id
*/
sendDE(id) {
this.serv.send(`DE#${id}#%`);
}
/**
* Sends health point command.
* @param {number} side the position
* @param {number} hp the health point
*/
sendHP(side, hp) {
this.serv.send(`HP#${side}#${hp}#%`);
}
/**
* Sends call mod command.
* @param {string} message to mod
*/
sendZZ(msg) {
this.serv.send(`ZZ#${msg}#%`);
}
/**
* Sends testimony command.
* @param {string} testimony type
*/
sendRT(testimony) {
if (this.chars[this.charID].side === "jud") {
this.serv.send(`RT#${testimony}#%`);
}
}
/**
* Requests to change the music to the specified track.
* @param {string} track the track ID
*/
sendMusicChange(track) {
this.serv.send(`MC#${track}#${this.charID}#%`);
}
/**
* Requests to leave the room and free the character slot.
*
* Note: This packet is undocumented. It is not implemented by
* either the AO2 client or tsuserver.
*/
sendLeaveRoom() {
this.serv.send("FC#%");
}
/**
* Begins the handshake process by sending an identifier
* to the server.
*/
joinServer() {
this.serv.send(`HI#${hdid}#%`);
this.serv.send("ID#webAO#2.3#%");
this.checkUpdater = setInterval(() => this.sendCheck(), 5000);
}
/**
* Load game resources.
*/
loadResources() {
// Set to playerID to server chat name
// TODO: Make a text box for this!
document.getElementById("OOC_name").value = "web" + this.playerID;
// Load evidence array to select
const evidence_select = document.getElementById("evi_select");
evidence_select.add(new Option("Custom", 0));
evidence_arr.forEach(evidence => {
evidence_select.add(new Option(evidence));
});
// Load background array to select
const background_select = document.getElementById("bg_select");
background_select.add(new Option("Custom", 0));
background_arr.forEach(background => {
background_select.add(new Option(background));
});
this.resources.map(async (resource) => {
// Check if image exists and replace `src` with an absolute URL
const spriteSrc = `${AO_HOST}themes/default/${resource.src}.gif`;
if (await fileExists(spriteSrc)) {
Object.assign(resource, {
src: spriteSrc,
duration: await viewport.getAnimLength(spriteSrc)
});
}
// Check if sfx exists and replace `sfx` with an absolute URL
if (resource.sfx) {
const sfxSrc = AO_HOST + resource.sfx.toLowerCase();
if (await fileExists(sfxSrc)) {
resource.sfx = sfxSrc;
}
}
});
}
/**
* Create observer to detect BBCode elements
* then manipulate them.
*/
initialObservBBCode() {
const target = document.getElementById("client_inner_chat");
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
const children = mutation.addedNodes;
if (children !== null) {
children.forEach(function (node) {
if (node.tagName === "C") {
node.style.color = node.getAttribute("a");
} else if (node.tagName === "M") {
if (node.hasAttribute("a")) {
node.style.backgroundColor = node.getAttribute("a");
} else {
node.style.backgroundColor = "yellow";
node.style.color = "black";
}
}
});
}
});
});
const config = {
attributes: true,
childList: true
};
observer.observe(target, config);
}
/**
* Requests to play as a specified character.
* @param {number} character the character ID
*/
sendCharacter(character) {
this.serv.send(`CC#${this.playerID}#${character}#web#%`);
}
/**
* Requests to select a music track.
* @param {number?} song the song to be played
*/
sendMusic(song) {
this.serv.send(`MC#${song}`);
}
/**
* Sends a keepalive packet.
*/
sendCheck() {
this.serv.send(`CH#${this.charID}#%`);
}
/**
* Triggered when a connection is established to the server.
*/
onOpen(_e) {
// XXX: Why does watching mean just SITTING there and doing nothing?
if (mode === "watch") {
document.getElementById("client_loading").style.display = "none";
document.getElementById("client_charselect").style.display = "none";
} else {
client.joinServer();
}
}
/**
* Triggered when the connection to the server closes.
* @param {CloseEvent} e
*/
onClose(e) {
console.error(`The connection was closed: ${e.reason} (${e.code})`);
if (e.code !== 1001) {
document.getElementById("client_error").style.display = "flex";
document.getElementById("client_loading").style.display = "none";
document.getElementById("error_id").textContent = e.code;
this.cleanup();
}
}
/**
* Triggered when a packet is received from the server.
* @param {MessageEvent} e
*/
onMessage(e) {
const msg = e.data;
console.debug(msg);
const lines = msg.split("%");
const args = lines[0].split("#");
const header = args[0];
if (!this.emit(header, args)) {
console.warn(`Invalid packet header ${header}`);
}
}
/**
* Triggered when an network error occurs.
* @param {ErrorEvent} e
*/
onError(e) {
console.error(`A network error occurred: ${e.reason} (${e.code})`);
document.getElementById("client_error").style.display = "flex";
document.getElementById("client_loading").style.display = "none";
document.getElementById("error_id").textContent = e.code;
this.cleanup();
}
cleanup() {
try {
this.serv.close(1001);
} finally {
clearInterval(this.checkUpdater);
}
}
/**
* XXX: a nasty hack made by gameboyprinter.
* @param {string} msg chat message to prepare for display
*/
prepChat(msg){
// TODO: make this less awful
return decodeBBCode(unescapeChat(decodeChat(msg)));
}
/**
* Handles an in-character chat message.
* @param {*} args packet arguments
*/
handleMS(args) {
// TODO: this if-statement might be a bug.
if (args[4] !== viewport.chatmsg.content) {
document.getElementById("client_inner_chat").innerHTML = "";
const chatmsg = {
preanim: escape(args[2]), // get preanim
nameplate: args[3], // TODO: parse INI to get this info
name: args[3],
speaking: "(b)" + escape(args[4]),
silent: "(a)" + escape(args[4]),
content: this.prepChat(args[5]), // Escape HTML tag, Use BBCode Only!
side: args[6],
sound: escape(args[7]),
type: args[8],
charid: args[9],
snddelay: args[10],
objection: args[11],
evidence: args[12],
flip: args[13],
flash: args[14],
color: args[15],
isnew: true,
};
if (chatmsg.charid === this.charID) {
resetICParams();
}
viewport.say(chatmsg); // no await
}
}
/**
* Handles an out-of-character chat message.
* @param {Array} args packet arguments
*/
handleCT(args) {
const oocLog = document.getElementById("client_ooclog");
oocLog.innerHTML += `${decodeChat(unescapeChat(args[1]))}: ${decodeChat(unescapeChat(args[2]))}\r\n`;
if (oocLog.scrollTop > oocLog.scrollHeight - 600) {
oocLog.scrollTop = oocLog.scrollHeight;
}
}
/**
* Handles a music change to an arbitrary resource.
* @param {Array} args packet arguments
*/
handleMC(args) {
const [ _packet, track, charID ] = args;
const music = viewport.music;
music.pause();
music.src = MUSIC_HOST + track.toLowerCase();
music.play();
if (args[2] >= 0) {
const musicname = this.chars[charID].name;
appendICLog(`${musicname} changed music to ${track}`);
} else {
appendICLog(`The music was changed to ${track}`);
}
}
/**
* Handles a music change to an arbitrary resource, with an offset in seconds.
* @param {Array} args packet arguments
*/
handleRMC(args) {
viewport.music.pause();
viewport.music = new Audio(this.musicList[args[1]]);
const music = viewport.music;
// Music offset + drift from song loading
music.totime = args[1];
music.offset = new Date().getTime() / 1000;
music.addEventListener("loadedmetadata", function () {
music.currentTime += parseFloat(music.totime + (new Date().getTime() / 1000 - music.offset)).toFixed(3);
music.play();
}, false);
}
/**
* Handles incoming character information, bundling multiple characters
* per packet.
* @param {Array} args packet arguments
*/
handleCI(args) {
document.getElementById("client_loadingtext").innerHTML = "Loading Character " + args[1];
this.serv.send("AN#" + ((args[1] / 10) + 1) + "#%");
for (let i = 2; i < args.length - 1; i++) {
if (i % 2 === 0) {
const chargs = args[i].split("&");
this.chars[args[i - 1]] = {
name: chargs[0],
desc: chargs[1],
evidence: chargs[3],
icon: AO_HOST + "characters/" + escape(chargs[0].toLowerCase()) + "/char_icon.png"
};
}
}
}
/**
* Handles incoming character information, containing only one character
* per packet.
* @param {Array} args packet arguments
*/
handleSC(args) {
document.getElementById("client_loadingtext").innerHTML = "Loading Characters";
for (let i = 1; i < args.length - 1; i++) {
const chargs = args[i].split("&");
this.chars[i - 1] = {
name: chargs[0],
desc: chargs[1],
evidence: chargs[3],
icon: AO_HOST + "characters/" + escape(chargs[0].toLowerCase()) + "/char_icon.png"
};
}
this.serv.send("RM#%");
}
/**
* Handles incoming evidence information, containing only one evidence
* item per packet.
*
* Mostly unimplemented in webAO.
* @param {Array} args packet arguments
*/
handleEI(args) {
document.getElementById("client_loadingtext").innerHTML = "Loading Evidence " + args[1];
//serv.send("AE#" + (args[1] + 1) + "#%");
this.serv.send("RM#%");
}
/**
* Handles incoming evidence list, all evidences at once
* item per packet.
*
* @param {Array} args packet arguments
*/
handleLE(args) {
this.evidences = [];
for (let i = 1; i < args.length - 1; i++) {
const arg = args[i].split("&");
this.evidences[i - 1] = {
name: decodeChat(unescapeChat(arg[0])),
desc: decodeChat(unescapeChat(arg[1])),
filename: escape(arg[2]),
icon: AO_HOST + "evidence/" + escape(arg[2].toLowerCase())
};
}
const evidence_box = document.getElementById("evidences");
evidence_box.innerHTML = "";
for (let i = 1; i <= this.evidences.length; i++) {
evidence_box.innerHTML += `
`;
}
}
/**
* Handles incoming music information, containing multiple entries
* per packet.
* @param {Array} args packet arguments
*/
handleEM(args) {
document.getElementById("client_loadingtext").innerHTML = "Loading Music " + args[1];
this.serv.send("AM#" + ((args[1] / 10) + 1) + "#%");
const hmusiclist = document.getElementById("client_musiclist");
for (let i = 2; i < args.length - 1; i++) {
if (i % 2 === 0) {
const newentry = document.createElement("OPTION");
newentry.text = args[i];
hmusiclist.options.add(newentry);
}
}
}
/**
* Handles incoming music information, containing only one entry
* per packet.
* @param {Array} args packet arguments
*/
handleSM(args) {
document.getElementById("client_loadingtext").innerHTML = "Loading Music ";
const hmusiclist = document.getElementById("client_musiclist");
let flagAudio = false;
for (let i = 1; i < args.length - 1; i++) {
// Check when found the song for the first time
if (/\.(?:wav|mp3|mp4|ogg|opus)$/i.test(args[i]) && !flagAudio) {
flagAudio = true;
}
if (flagAudio) {
// After reached the audio put everything in the music list
const newentry = document.createElement("OPTION");
newentry.text = args[i];
hmusiclist.options.add(newentry);
} else {
// Create area button
const newarea = document.createElement("SPAN");
newarea.className = "location-box";
newarea.textContent = args[i];
newarea.onclick = function () {
area_click(this);
};
document.getElementById("areas").appendChild(newarea);
}
}
// We need to check if the last area that we got was actually a category
// header for music. If it was, then move it over to the music list.
const area_box = document.getElementById("areas");
if (area_box.lastChild.textContent.startsWith("=")) {
const audio_title = document.createElement("OPTION");
audio_title.text = area_box.lastChild.textContent;
hmusiclist.insertBefore(audio_title, hmusiclist.firstChild);
area_box.removeChild(area_box.lastChild);
}
this.serv.send("RD#%");
}
/**
* Handles the kicked packet
* @param {Array} args packet arguments
*/
handleKB(args) {
document.getElementById("client_loadingtext").innerHTML = "Kicked: " + args[1];
}
/**
* Handles the banned packet
* @param {Array} args packet arguments
*/
handleBD(args) {
document.getElementById("client_loadingtext").innerHTML = "Banned: " + args[1];
}
/**
* Handles incoming music information, containing all entries
* in the same packet.
* @param {Array} args packet arguments
*/
handlemusic(args) {
for (let i = 0; i < args.length / 2; i++) {
this.musicList[args[2 * i]] = args[2 * i + 1];
}
}
/**
* Handles the handshake completion packet, meaning the player
* is ready to select a character.
*
* @param {Array} args packet arguments
*/
handleDONE(_args) {
document.getElementById("client_loading").style.display = "none";
document.getElementById("client_charselect").style.display = "block";
}
/**
* Handles a background change.
* @param {Array} args packet arguments
*/
handleBN(args) {
viewport.bgname = escape(args[1]);
const bg_index = getIndexFromSelect("bg_select", escape(args[1]));
document.getElementById("bg_select").selectedIndex = bg_index;
updateBackgroundPreview();
if (bg_index === 0) {
document.getElementById("bg_filename").value = args[1];
}
document.getElementById("bg_preview").src = AO_HOST + "background/" + escape(args[1].toLowerCase()) + "/defenseempty.png";
if (this.charID === -1) {
changeBackground("jud");
} else {
changeBackground(this.chars[this.charID].side);
}
}
handleNBG(_args) {
// TODO (set by sD)
}
/**
* Handles a change in the health bars' states.
* @param {Array} args packet arguments
*/
handleHP(args) {
const percent_hp = args[2] * 10;
if (args[1] === 1) {
// Def hp
this.hp[0] = args[2];
$("#client_defense_hp > .health-bar").animate({
"width": percent_hp + "%"
}, 500);
} else {
// Pro hp
this.hp[1] = args[2];
$("#client_prosecutor_hp > .health-bar").animate({
"width": percent_hp + "%"
}, 500);
}
}
/**
* Handles a testimony states.
* @param {Array} args packet arguments
*/
handleRT(args) {
if (args[1] === "testimony1") {
//Witness Testimony
this.testimonyID = 1;
} else {
//Cross Examination
this.testimonyID = 2;
}
viewport.initTestimonyUpdater();
}
/**
* Handles a call mod message.
* @param {Array} args packet arguments
*/
handleZZ(args) {
const oocLog = document.getElementById("client_ooclog");
oocLog.innerHTML += `$Alert: ${decodeChat(unescapeChat(args[1]))}\r\n`;
if (oocLog.scrollTop > oocLog.scrollHeight - 60) {
oocLog.scrollTop = oocLog.scrollHeight;
}
}
/**
* Handles the issuance of a player ID by the server.
* @param {Array} args packet arguments
*/
handleID(args) {
this.playerID = args[1];
}
handlePN(_args) {
this.serv.send("askchaa#%");
}
/**
* Doesn't handle the change of players in an area.
* webAO doesn't have this feature yet, but i want the warning to go away.
* @param {Array} args packet arguments
*/
handleARUP(args) {
;
}
/**
* Received when the server announces its server info,
* but we use it as a cue to begin retrieving characters.
* @param {Array} args packet arguments
*/
handleSI(_args) {
if (oldLoading) {
this.serv.send("askchar2#%");
} else {
this.serv.send("RC#%");
}
}
/**
* Handles the list of all used and vacant characters.
* @param {Array} args packet arguments
*/
handleCharsCheck(args) {
document.getElementById("client_chartable").innerHTML = "";
let tr;
for (let i = 0; i < this.chars.length; i++) {
if (i % CHAR_SELECT_WIDTH === 0) {
tr = document.createElement("TR");
}
const td = document.createElement("TD");
let icon_chosen = "";
const thispick = this.chars[i].icon;
if (args[i + 1] === "-1") {
icon_chosen = " dark";
}
td.innerHTML = `
";
tr.appendChild(td);
if (i % CHAR_SELECT_WIDTH === 0) {
document.getElementById("client_chartable").appendChild(tr);
}
}
//changeBackground("def");
}
/**
* Handles the server's assignment of a character for the player to use.
* @param {Array} args packet arguments
*/
async handlePV(args) {
this.charID = args[3];
document.getElementById("client_charselect").style.display = "none";
document.getElementById("client_inputbox").style.display = "";
const me = this.character;
const emotes = this.emotes;
const emotesList = document.getElementById("client_emo");
emotesList.innerHTML = ""; // Clear emote box
emotesList.style.display = "";
const data = await request(AO_HOST + "characters/" + escape(this.character.name.toLowerCase()) + "/char.ini");
const ini = INI.parse(data);
me.side = ini.Options.side;
updateActionCommands(me.side);
for (let i = 1; i <= ini.Emotions.number; i++) {
const emoteinfo = ini.Emotions[i].split("#");
const esfx = ini.SoundN ? ini.SoundN[i] : "0";
const esfxd = ini.SoundT ? ini.SoundT[i] : "0";
// Make sure the asset server is case insensitive, or that everything on it is lowercase
emotes[i] = {
desc: emoteinfo[0].toLowerCase(),
speaking: emoteinfo[1].toLowerCase(),
silent: emoteinfo[2].toLowerCase(),
zoom: emoteinfo[3],
sfx: esfx.toLowerCase(),
sfxdelay: esfxd,
button_off: AO_HOST + `characters/${escape(me.name).toLowerCase()}/emotions/button${i}_off.png`,
button_on: AO_HOST + `characters/${escape(me.name).toLowerCase()}/emotions/button${i}_on.png`
};
emotesList.innerHTML +=
`
`;
}
pickEmotion(1);
}
}
class Viewport {
constructor() {
this.textnow = "";
this.chatmsg = {
"isnew": false,
"content": "",
"objection": "0",
"sound": "",
"startpreanim": false,
"startspeaking": false,
"side": null,
"color": "0",
"snddelay": 0,
"preanimdelay": 0
};
this.blip = new Audio(AO_HOST + "sounds/general/sfx-blipmale.wav");
this.blip.volume = 0.5;
// Allocate multiple blip audio channels to make blips less jittery
// TODO: read blip type ("gender") from ini
this.blipChannels = new Array(6);
this.blipChannels.fill(new Audio(AO_HOST + "sounds/general/sfx-blipmale.wav"))
.forEach(channel => channel.volume = 0.5);
this.currentBlipChannel = 0;
this.sfxaudio = new Audio(AO_HOST + "sounds/general/sfx-blipmale.wav");
this.sfxplayed = 0;
this.music = new Audio();
this.music.play();
this.updater = null;
this.testimonyUpdater = null;
this.bgname = "gs4";
this.testimonyTimer = 0;
this.shoutTimer = 0;
this.textTimer = 0;
this._animating = false;
}
/**
* Returns whether or not the viewport is busy
* performing a task (animating).
*/
get isAnimating() {
return this._animating;
}
/**
* Sets the volume of the blip sound.
* @param {number} volume
*/
set blipVolume(volume) {
this.blipChannels.forEach(channel => channel.volume = volume);
}
/**
* Returns the path which the background is located in.
*/
get bgFolder() {
return `${AO_HOST}background/${this.bgname.toLowerCase()}/`;
}
/**
* Sets a new emote.
* @param {object} chatmsg the new chat message
*/
async say(chatmsg) {
this.chatmsg = chatmsg;
appendICLog(chatmsg.content, chatmsg.nameplate);
changeBackground(chatmsg.side);
this.textnow = "";
this.sfxplayed = 0;
this.textTimer = 0;
this._animating = true;
clearTimeout(this.updater);
// If preanim existed then determine the length
if (chatmsg.preanim !== "-") {
const delay = await this.getAnimLength(`${AO_HOST}characters/${escape(chatmsg.name.toLowerCase())}/${chatmsg.preanim.toLowerCase()}.gif`);
chatmsg.preanimdelay = delay;
this.initUpdater(delay);
} else {
this.initUpdater(0);
}
}
/**
* Intialize updater
* @param {number} animdelay the length of pre-animation
*/
initUpdater(animdelay) {
viewport.chatmsg.preanimdelay = parseInt(animdelay);
viewport.updater = setTimeout(() => viewport.tick(), UPDATE_INTERVAL);
}
/**
* Intialize testimony updater
*/
initTestimonyUpdater() {
const testimonyFilenames = {
1: "witnesstestimony",
2: "crossexamination"
};
const testimony = testimonyFilenames[client.testimonyID];
if (!testimony) {
console.warn(`Invalid testimony ID ${client.testimonyID}`);
return;
}
(new Audio(client.resources[testimony].sfx)).play();
const testimonyOverlay = document.getElementById("client_testimony");
testimonyOverlay.src = client.resources[testimony].src;
testimonyOverlay.style.display = "";
this.testimonyTimer = 0;
this.testimonyUpdater = setTimeout(() => this.updateTestimony(), UPDATE_INTERVAL);
}
/**
* Gets animation length. If the animation cannot be found, it will
* silently fail and return 0 instead.
* @param {string} filename the animation file name
*/
async getAnimLength(filename) {
try {
const file = await request(filename);
return gify.getInfo(file).duration;
} catch (err) {
return 0;
}
}
/**
* Updates the testimony overaly
*/
updateTestimony() {
const testimonyFilenames = {
1: "witnesstestimony",
2: "crossexamination"
};
// Update timer
this.testimonyTimer = this.testimonyTimer + UPDATE_INTERVAL;
const testimony = testimonyFilenames[client.testimonyID];
const resource = client.resources[testimony];
if (!resource) {
this.disposeTestimony();
return;
}
if (this.testimonyTimer >= resource.duration) {
this.disposeTestimony();
} else {
this.testimonyUpdater = setTimeout(() => this.updateTestimony(), UPDATE_INTERVAL);
}
}
/**
* Dispose the testimony overlay
*/
disposeTestimony() {
client.testimonyID = 0;
this.testimonyTimer = 0;
document.getElementById("client_testimony").style.display = "none";
clearTimeout(this.testimonyUpdater);
}
/**
* Updates the chatbox based on the given text.
*
* XXX: This relies on a global variable `this.chatmsg`!
*/
tick() {
const nameBox = document.getElementById("client_name");
const chatBox = document.getElementById("client_chat");
const charSprite = document.getElementById("client_char");
const eviBox = document.getElementById("client_evi");
const background = document.getElementById("client_background");
const shoutSprite = document.getElementById("client_shout");
const chatBoxInner = document.getElementById("client_inner_chat");
// Flip the character
if (this.chatmsg.flip === "1") {
charSprite.style.transform = "scaleX(-1)";
} else {
charSprite.style.transform = "scaleX(1)";
}
if (this._animating) {
this.updater = setTimeout(() => this.tick(), UPDATE_INTERVAL);
}
if (this.chatmsg.isnew) {
// Reset screen background
background.style.backgroundColor = "transparent";
// Hide message and evidence window
nameBox.style.display = "none";
chatBox.style.display = "none";
eviBox.style.opacity = "0";
eviBox.style.height = "0%";
const shouts = {
"1": "holdit",
"2": "objection",
"3": "takethat"
};
const shout = shouts[this.chatmsg.objection];
if (shout) {
shoutSprite.src = client.resources[shout]["src"];
(new Audio(`${AO_HOST}characters/${this.chatmsg.name.toLowerCase()}/${shout}.wav`)).play();
this.shoutTimer = 850;
} else {
this.shoutTimer = 0;
}
this.chatmsg.isnew = false;
this.chatmsg.startpreanim = true;
}
if (this.textTimer >= this.shoutTimer && this.chatmsg.startpreanim) {
// Effect stuff
if (this.chatmsg.flash === "2") {
// Shake screen
this.sfxaudio.pause();
this.sfxplayed = 1;
this.sfxaudio.src = AO_HOST + "sounds/general/sfx-stab.wav";
this.sfxaudio.play();
$("#client_gamewindow").effect("shake", {
"direction": "up"
});
} else if (this.chatmsg.flash === "1") {
// Flash screen
background.style.backgroundColor = "white";
this.sfxaudio.pause();
this.sfxplayed = 1;
this.sfxaudio.src = AO_HOST + "sounds/general/sfx-realization.wav";
this.sfxaudio.play();
$("#client_gamewindow").effect("pulsate",{times:1},200);
}
// Pre-animation stuff
if (this.chatmsg.preanimdelay > 0) {
shoutSprite.src = "misc/placeholder.gif";
const charName = escape(this.chatmsg.name.toLowerCase());
const preanim = this.chatmsg.preanim.toLowerCase();
charSprite.src = `${AO_HOST}characters/${charName}/${preanim}.gif`;
}
this.chatmsg.startpreanim = false;
this.chatmsg.startspeaking = true;
} else if (this.textTimer >= this.shoutTimer + this.chatmsg.preanimdelay && !this.chatmsg.startpreanim) {
if (this.chatmsg.startspeaking) {
if (this.chatmsg.evidence > 0) {
// Prepare evidence
eviBox.style.backgroundImage = "url('" + client.evidences[this.chatmsg.evidence - 1].icon + "')";
if (this.chatmsg.side === "def") {
// Only def show evidence on right
eviBox.style.right = "1.5em";
eviBox.style.left = "initial";
$("#client_evi").animate({
height: "30%",
opacity: 1
}, 250);
} else {
eviBox.style.right = "initial";
eviBox.style.left = "1.5em";
$("#client_evi").animate({
height: "30%",
opacity: 1
}, 250);
}
}
nameBox.style.display = "block";
nameBox.style.fontSize = (nameBox.offsetHeight * 0.7) + "px";
while (nameBox.hasChildNodes()) {
nameBox.removeChild(nameBox.firstChild);
}
nameBox.appendChild(document.createTextNode(this.chatmsg.nameplate));
chatBox.style.display = "block";
chatBox.style.fontSize = (chatBox.offsetHeight * 0.25) + "px";
const colors = {
"0": "#ffffff",
"1": "#00ff00",
"2": "#ff0000",
"3": "#ffaa00",
"4": "#0000ff",
"5": "#ffff00",
"6": "#aa00aa"
};
chatBoxInner.style.color = colors[this.chatmsg.color] || "#ffffff";
this.chatmsg.startspeaking = false;
if (this.chatmsg.preanimdelay === 0) {
shoutSprite.src = "misc/placeholder.gif";
changeBackground(this.chatmsg.side);
}
charSprite.src = AO_HOST + "characters/" + escape(this.chatmsg.name.toLowerCase()) + "/" + this.chatmsg.speaking.toLowerCase() + ".gif";
if (this.textnow === this.chatmsg.content) {
charSprite.src = AO_HOST + "characters/" + escape(this.chatmsg.name.toLowerCase()) + "/" + this.chatmsg.silent.toLowerCase() + ".gif";
this._animating = false;
clearTimeout(this.updater);
}
} else {
if (this.textnow !== this.chatmsg.content) {
if (this.chatmsg.content.charAt(this.textnow.length) !== " ") {
this.blipChannels[this.currentBlipChannel].play();
this.currentBlipChannel++;
this.currentBlipChannel %= this.blipChannels.length;
}
this.textnow = this.chatmsg.content.substring(0, this.textnow.length + 1);
while (chatBoxInner.hasChildNodes()) {
chatBoxInner.removeChild(chatBoxInner.firstChild);
}
chatBoxInner.appendChild(document.createTextNode(this.textnow));
if (this.textnow === this.chatmsg.content) {
this.textTimer = 0;
this._animating = false;
charSprite.src = AO_HOST + "characters/" + escape(this.chatmsg.name.toLowerCase()) + "/" + this.chatmsg.silent.toLowerCase() + ".gif";
clearTimeout(this.updater);
}
}
}
}
if (!this.sfxplayed && this.chatmsg.snddelay + this.shoutTimer >= this.textTimer) {
this.sfxaudio.pause();
this.sfxplayed = 1;
if (this.chatmsg.sound !== "0" && this.chatmsg.sound !== "1") {
this.sfxaudio.src = AO_HOST + "sounds/general/" + escape(this.chatmsg.sound.toLowerCase()) + ".wav";
this.sfxaudio.play();
}
}
this.textTimer = this.textTimer + UPDATE_INTERVAL;
}
}
class INI {
static parse(data) {
const regex = {
section: /^\s*\[\s*([^\]]*)\s*\]\s*$/,
param: /^\s*([\w.\-_]+)\s*=\s*(.*?)\s*$/,
comment: /^\s*;.*$/
};
const value = {};
const lines = data.split(/\r\n|\r|\n/);
let section;
lines.forEach(function (line) {
if (regex.comment.test(line)) {
return;
} else if (line.length === 0) {
return;
} else if (regex.param.test(line)) {
const match = line.match(regex.param);
if (section) {
value[section][match[1]] = match[2];
} else {
value[match[1]] = match[2];
}
} else if (regex.section.test(line)) {
const match = line.match(regex.section);
value[match[1]] = {};
section = match[1];
}
});
return value;
}
}
/**
* Triggered when the Return key is pressed on the out-of-character chat input box.
* @param {KeyboardEvent} event
*/
export function onOOCEnter(event) {
if (event.keyCode === 13) {
client.sendOOC(document.getElementById("client_oocinputbox").value);
document.getElementById("client_oocinputbox").value = "";
}
}
window.onOOCEnter = onOOCEnter;
/**
* Triggered when the Return key is pressed on the in-character chat input box.
* @param {KeyboardEvent} event
*/
export function onEnter(event) {
if (event.keyCode === 13) {
const mychar = client.character;
const myemo = client.emote;
const myevi = client.evidence;
const myflip = ((client.flip) ? 1 : 0);
const mycolor = document.getElementById("textcolor").value;
let ssfxname = "0";
let ssfxdelay = "0";
if (document.getElementById("sendsfx").checked) {
ssfxname = myemo.sfx;
ssfxdelay = myemo.sfxdelay;
}
client.sendIC(myemo.speaking, mychar.name, myemo.silent,
document.getElementById("client_inputbox").value, mychar.side,
ssfxname, myemo.zoom, ssfxdelay, selectedShout, myevi, myflip,
selectedEffect, mycolor);
}
}
window.onEnter = onEnter;
/**
* Resets the IC parameters for the player to enter a new chat message.
* This should only be called when the player's previous chat message
* was successfully sent/presented.
*/
function resetICParams() {
document.getElementById("client_inputbox").value = "";
if (selectedEffect) {
document.getElementById("button_effect_" + selectedEffect).className = "client_button";
selectedEffect = 0;
}
if (selectedShout) {
document.getElementById("button_" + selectedShout).className = "client_button";
selectedShout = 0;
}
}
/**
* Triggered when an item on the music list is clicked.
* @param {MouseEvent} event
*/
export function musiclist_click(_event) {
const playtrack = document.getElementById("client_musiclist").value;
client.sendMusicChange(playtrack);
// This is here so you can't actually select multiple tracks,
// even though the select tag has the multiple option to render differently
let musiclist_elements = document.getElementById("client_musiclist").selectedOptions;
for(let i = 0; i < musiclist_elements.length; i++){
musiclist_elements[i].selected = false;
}
}
window.musiclist_click = musiclist_click;
/**
* Triggered when an item on the area list is clicked.
* @param {MouseEvent} event
*/
export function area_click(el) {
const area = el.textContent;
client.sendMusicChange(area);
const areaHr = document.createElement("div");
areaHr.className = "hrtext";
areaHr.textContent = `switched to ${el.textContent}`;
document.getElementById("client_log").appendChild(areaHr);
}
window.area_click = area_click;
/**
* Triggered by the music volume slider.
*/
export function changeMusicVolume() {
viewport.music.volume = document.getElementById("client_mvolume").value / 100;
}
window.changeMusicVolume = changeMusicVolume;
/**
* Triggered by the sound effect volume slider.
*/
export function changeSFXVolume() {
viewport.sfxaudio.volume = document.getElementById("client_svolume").value / 100;
}
window.changeSFXVolume = changeSFXVolume;
/**
* Triggered by the blip volume slider.
*/
export function changeBlipVolume() {
viewport.blipVolume = document.getElementById("client_bvolume").value / 100;
}
window.changeBlipVolume = changeBlipVolume;
/**
* Triggered when a character icon is clicked in the character selection menu.
* @param {MouseEvent} event
*/
export function changeCharacter(_event) {
client.sendLeaveRoom();
document.getElementById("client_charselect").style.display = "block";
document.getElementById("client_emo").innerHTML = "";
}
window.changeCharacter = changeCharacter;
/**
* Triggered when there was an error loading a character sprite.
* @param {HTMLImageElement} image the element containing the missing image
*/
export function charError(image) {
image.onerror = "";
image.src = "misc/placeholder.gif";
return true;
}
window.imgError = imgError;
/**
* Triggered when there was an error loading a generic sprite.
* @param {HTMLImageElement} image the element containing the missing image
*/
export function imgError(image) {
image.onerror = "";
image.src = ""; //unload so the old sprite doesn't persist
return true;
}
window.imgError = imgError;
/**
* Triggered when there was an error loading a character icon.
* @param {HTMLImageElement} image the element containing the missing image
*/
export function demoError(image) {
image.onerror = "";
image.src = "misc/placeholder.png";
return true;
}
window.demoError = demoError;
/**
* Make a GET request for a specific URI.
* @param {string} url the URI to be requested
* @returns response data
* @throws {Error} if status code is not 2xx, or a network error occurs
*/
async function request(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener("error", () => {
const err = new Error(`Request for ${url} failed: ${xhr.statusText}`);
err.code = xhr.status;
reject(err);
});
xhr.addEventListener("abort", () => {
const err = new Error(`Request for ${url} was aborted!`);
err.code = xhr.status;
reject(err);
});
xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) {
const err = new Error(`Request for ${url} failed with status code ${xhr.status}`);
err.code = xhr.status;
reject(err);
} else {
resolve(xhr.response);
}
});
xhr.open("GET", url, true);
xhr.send();
});
}
/**
* Checks if a file exists at the specified URI.
* @param {string} url the URI to be checked
*/
async function fileExists(url) {
try {
await request(url);
return true;
} catch (err) {
if (err.code >= 400) return false;
else throw err;
}
}
/**
* Changes the viewport background based on a given position.
*
* Valid positions: `def, pro, hld, hlp, wit, jud`
* @param {string} position the position to change into
*/
async function changeBackground(position) {
const bgfolder = viewport.bgFolder;
const positions = {
def: {
bg: "defenseempty.png",
desk: { ao2: "defensedesk.png", ao1: "bancodefensa.png" },
speedLines: "defense_speedlines.gif"
},
pro: {
bg: "prosecutorempty.png",
desk: { ao2: "prosecutiondesk.png", ao1: "bancoacusacion.png" },
speedLines: "prosecution_speedlines.gif"
},
hld: {
bg: "helperstand.png",
desk: null,
speedLines: "defense_speedlines.gif"
},
hlp: {
bg: "prohelperstand.png",
desk: null,
speedLines: "prosecution_speedlines.gif"
},
wit: {
bg: "witnessempty.png",
desk: { ao2: "stand.png", ao1: "estrado.png" },
speedLines: "prosecution_speedlines.gif"
},
jud: {
bg: "judgestand.png",
desk: null,
speedLines: "prosecution_speedlines.gif"
}
};
const { bg, desk, speedLines } = positions[position];
document.getElementById("client_fg").style.display = "none";
if (viewport.chatmsg.type === 5) {
document.getElementById("client_court").src = `${AO_HOST}themes/default/${speedLines}`;
} else {
document.getElementById("client_court").src = bgfolder + bg;
if (desk) {
const deskFilename = await fileExists(bgfolder + desk.ao2) ? desk.ao2 : desk.ao1;
document.getElementById("client_bench").src = bgfolder + deskFilename;
document.getElementById("client_bench").style.display = "block";
} else {
document.getElementById("client_bench").style.display = "none";
}
}
}
/**
* Triggered when the reconnect button is pushed.
*/
export function ReconnectButton() {
client.cleanup();
client = new Client(serverIP);
if (client) {
mode = "join"; // HACK: see client.onOpen
document.getElementById("client_error").style.display = "none";
}
}
window.ReconnectButton = ReconnectButton;
/**
* Triggered when the retry button is pushed (during the loading process).
*/
export function RetryButton() {
client.joinServer();
}
window.RetryButton = RetryButton;
/**
* Appends a message to the in-character chat log.
* @param {string} msg the string to be added
* @param {string} name the name of the sender
*/
function appendICLog(msg, name = "", time = new Date()) {
const entry = document.createElement("p");
const nameField = document.createElement("span");
nameField.id = "iclog_name";
nameField.appendChild(document.createTextNode(name));
entry.appendChild(nameField);
entry.appendChild(document.createTextNode(msg));
// Only put a timestamp if the minute has changed.
if (lastICMessageTime.getMinutes() !== time.getMinutes()) {
const timeStamp = document.createElement("span");
timeStamp.id = "iclog_time";
timeStamp.innerText = time.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit"
});
entry.appendChild(timeStamp);
}
const clientLog = document.getElementById("client_log");
clientLog.appendChild(entry);
/* This is a little buggy - some troubleshooting might be desirable */
if (clientLog.scrollTop > clientLog.scrollHeight - 800) {
clientLog.scrollTop = clientLog.scrollHeight;
}
lastICMessageTime = new Date();
}
/**
* Requests to play as a character.
* @param {number} ccharacter the character ID; if this is a large number,
* then spectator is chosen instead.
*/
export function pickChar(ccharacter) {
if (ccharacter < 1000) {
client.sendCharacter(ccharacter);
} else {
// Spectator
document.getElementById("client_charselect").style.display = "none";
document.getElementById("client_inputbox").style.display = "none";
document.getElementById("client_emo").style.display = "none";
}
}
window.pickChar = pickChar;
/**
* Highlights and selects an emotion for in-character chat.
* @param {string} emo the new emotion to be selected
*/
export function pickEmotion(emo) {
if (client.selectedEmote !== -1) {
document.getElementById("emo_" + client.selectedEmote).src = client.emote.button_off;
}
client.selectedEmote = emo;
document.getElementById("emo_" + emo).src = client.emote.button_on;
}
window.pickEmotion = pickEmotion;
/**
* Highlights and selects an evidence for in-character chat.
* @param {string} evidence the evidence to be presented
*/
export function pickEvidence(evidence) {
if (client.selectedEvidence !== evidence) {
//Update selected evidence
if (client.selectedEvidence > 0) {
document.getElementById("evi_" + client.selectedEvidence).className = "client_button";
}
document.getElementById("evi_" + evidence).className = "client_button dark";
client.selectedEvidence = evidence;
// Show evidence on information window
document.getElementById("evi_name").value = client.evidences[evidence - 1].name;
document.getElementById("evi_desc").value = client.evidences[evidence - 1].desc;
// Update icon
const icon_id = getIndexFromSelect("evi_select", client.evidences[evidence - 1].filename);
document.getElementById("evi_select").selectedIndex = icon_id;
if (icon_id === 0) {
document.getElementById("evi_filename").value = client.evidences[evidence - 1].filename;
}
updateEvidenceIcon();
// Update button
document.getElementById("evi_add").className = "client_button hover_button inactive";
document.getElementById("evi_edit").className = "client_button hover_button";
document.getElementById("evi_cancel").className = "client_button hover_button";
document.getElementById("evi_del").className = "client_button hover_button";
} else {
cancelEvidence();
}
}
window.pickEvidence = pickEvidence;
/**
* Add evidence.
*/
export function addEvidence() {
const evidence_select = document.getElementById("evi_select");
client.sendPE(document.getElementById("evi_name").value,
document.getElementById("evi_desc").value,
evidence_select.selectedIndex === 0 ?
document.getElementById("evi_filename").value :
evidence_select.options[evidence_select.selectedIndex].text
);
cancelEvidence();
}
window.addEvidence = addEvidence;
/**
* Edit selected evidence.
*/
export function editEvidence() {
const evidence_select = document.getElementById("evi_select");
const id = parseInt(client.selectedEvidence) - 1;
client.sendEE(id,
document.getElementById("evi_name").value,
document.getElementById("evi_desc").value,
evidence_select.selectedIndex === 0 ?
document.getElementById("evi_filename").value :
evidence_select.options[evidence_select.selectedIndex].text
);
cancelEvidence();
}
window.editEvidence = editEvidence;
/**
* Delete selected evidence.
*/
export function deleteEvidence() {
const id = parseInt(client.selectedEvidence) - 1;
client.sendDE(id);
cancelEvidence();
}
window.deleteEvidence = deleteEvidence;
/**
* Cancel evidence selection.
*/
export function cancelEvidence() {
//Clear evidence data
if (client.selectedEvidence > 0) {
document.getElementById("evi_" + client.selectedEvidence).className = "client_button";
}
client.selectedEvidence = 0;
// Clear evidence on information window
document.getElementById("evi_select").selectedIndex = 0;
updateEvidenceIcon(); // Update icon widget
document.getElementById("evi_filename").value = "";
document.getElementById("evi_name").value = "";
document.getElementById("evi_desc").value = "";
document.getElementById("evi_icon").style.backgroundImage = "url('misc/empty.png')"; //Clear icon
// Update button
document.getElementById("evi_add").className = "client_button hover_button";
document.getElementById("evi_edit").className = "client_button hover_button inactive";
document.getElementById("evi_cancel").className = "client_button hover_button inactive";
document.getElementById("evi_del").className = "client_button hover_button inactive";
}
window.cancelEvidence = cancelEvidence;
/**
* Find index of anything in select box.
* @param {string} select_box the select element name
* @param {string} value the value that need to be compared
*/
export function getIndexFromSelect(select_box, value) {
//Find if icon alraedy existed in select box
const select_element = document.getElementById(select_box);
for (let i = 1; i < select_element.length; ++i) {
if (select_element.options[i].value === value) {
return i;
}
}
return 0;
}
window.getIndexFromSelect = getIndexFromSelect;
/**
* Update evidence icon.
*/
export function updateEvidenceIcon() {
const evidence_select = document.getElementById("evi_select");
const evidence_filename = document.getElementById("evi_filename");
const evidence_iconbox = document.getElementById("evi_icon");
if (evidence_select.selectedIndex === 0) {
evidence_filename.style.display = "initial";
evidence_iconbox.style.backgroundImage = `url(${AO_HOST}evidence/${evidence_filename.value.toLowerCase()})`;
} else {
evidence_filename.style.display = "none";
evidence_iconbox.style.backgroundImage = `url(${AO_HOST}evidence/${evidence_select.value.toLowerCase()})`;
}
}
window.updateEvidenceIcon = updateEvidenceIcon;
/**
* Update evidence icon.
*/
export function updateActionCommands(side) {
if (side === "jud") {
document.getElementById("judge_action").style.display = "inline-table";
document.getElementById("no_action").style.display = "none";
} else {
document.getElementById("judge_action").style.display = "none";
document.getElementById("no_action").style.display = "inline-table";
}
// Update role selector
for (let i = 0, role_select = document.getElementById("role_select").options; i < role_select.length; i++) {
if (side === role_select[i].value) {
role_select.selectedIndex = i;
return;
}
}
}
window.updateActionCommands = updateActionCommands;
/**
* Change background via OOC.
*/
export function changeBackgroundOOC() {
const selectedBG = document.getElementById("bg_select");
const changeBGCommand = document.getElementById("bg_command").value;
const bgFilename = document.getElementById("bg_filename");
let filename = "";
if (selectedBG.selectedIndex === 0) {
filename = bgFilename.value;
} else {
filename = selectedBG.value;
}
client.sendOOC("/" + changeBGCommand.replace("$1", filename));
}
window.changeBackgroundOOC = changeBackgroundOOC;
/**
* Change role via OOC.
*/
export function changeRoleOOC() {
const role_select = document.getElementById("role_select");
const role_command = document.getElementById("role_command").value;
client.sendOOC("/" + role_command.replace("$1", role_select.value));
updateActionCommands(role_select.value);
}
window.changeRoleOOC = changeRoleOOC;
/**
* Random character via OOC.
*/
export function randomCharacterOOC() {
client.sendOOC("/" + document.getElementById("randomchar_command").value);
}
window.randomCharacterOOC = randomCharacterOOC;
/**
* Call mod.
*/
export function callMod() {
$("#callmod_dialog").dialog("open");
}
window.callMod = callMod;
/**
* Declare witness testimony.
*/
export function initWT() {
client.sendRT("testimony1");
}
window.initWT = initWT;
/**
* Declare cross examination.
*/
export function initCE() {
client.sendRT("testimony2");
}
window.initCE = initCE;
/**
* Increment defense health point.
*/
export function addHPD() {
client.sendHP(1, String(parseInt(client.hp[0]) + 1));
}
window.addHPD = addHPD;
/**
* Decrement defense health point.
*/
export function redHPD() {
client.sendHP(1, String(parseInt(client.hp[0]) - 1));
}
window.redHPD = redHPD;
/**
* Increment prosecution health point.
*/
export function addHPP() {
client.sendHP(2, String(parseInt(client.hp[1]) + 1));
}
window.addHPP = addHPP;
/**
* Decrement prosecution health point.
*/
export function redHPP() {
client.sendHP(2, String(parseInt(client.hp[1]) - 1));
}
window.redHPP = redHPP;
/**
* Update background preview.
*/
export function updateBackgroundPreview() {
const background_select = document.getElementById("bg_select");
const background_filename = document.getElementById("bg_filename");
const background_preview = document.getElementById("bg_preview");
if (background_select.selectedIndex === 0) {
background_filename.style.display = "initial";
background_preview.src = AO_HOST + "background/" + background_filename.value.toLowerCase() + "/defenseempty.png";
} else {
background_filename.style.display = "none";
background_preview.src = AO_HOST + "background/" + background_select.value.toLowerCase() + "/defenseempty.png";
}
}
window.updateBackgroundPreview = updateBackgroundPreview;
/**
* Highlights and selects an effect for in-character chat.
* If the same effect button is selected, then the effect is canceled.
* @param {string} effect the new effect to be selected
*/
export function toggleEffect(effect) {
if (effect === selectedEffect) {
document.getElementById("button_effect_" + effect).className = "client_button";
selectedEffect = 0;
} else {
document.getElementById("button_effect_" + effect).className = "client_button dark";
if (selectedEffect) {
document.getElementById("button_effect_" + selectedEffect).className = "client_button";
}
selectedEffect = effect;
}
}
window.toggleEffect = toggleEffect;
/**
* Toggle flip for in-character chat.
*/
export function toggleFlip() {
if (client.flip) {
document.getElementById("button_flip").className = "client_button";
} else {
document.getElementById("button_flip").className = "client_button dark";
}
client.flip = !client.flip;
}
window.toggleFlip = toggleFlip;
/**
* Toggle presentable for presenting evidence in-character chat.
*/
export function togglePresent() {
if (client.presentable) {
document.getElementById("button_present").className = "client_button";
} else {
document.getElementById("button_present").className = "client_button dark";
}
client.presentable = !client.presentable;
}
window.togglePresent = togglePresent;
/**
* Highlights and selects a menu.
* @param {string} menu the menu to be selected
*/
export function toggleMenu(menu) {
if (menu !== selectedMenu) {
document.getElementById("menu_" + menu).className = "menu_icon active";
document.getElementById("content_" + menu).className = "menu_content active";
document.getElementById("menu_" + selectedMenu).className = "menu_icon";
document.getElementById("content_" + selectedMenu).className = "menu_content";
selectedMenu = menu;
}
}
window.toggleMenu = toggleMenu;
/**
* Highlights and selects a shout for in-character chat.
* If the same shout button is selected, then the shout is canceled.
* @param {string} shout the new shout to be selected
*/
export function toggleShout(shout) {
if (shout === selectedShout) {
document.getElementById("button_" + shout).className = "client_button";
selectedShout = 0;
} else {
document.getElementById("button_" + shout).className = "client_button dark";
if (selectedShout) {
document.getElementById("button_" + selectedShout).className = "client_button";
}
selectedShout = shout;
}
}
window.toggleShout = toggleShout;
/**
* Escapes a string to be HTML-safe.
*
* XXX: This is unnecessary if we use `createTextNode` instead!
* @param {string} unsafe an unsanitized string
*/
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Escapes a string to AO1 escape codes.
* @param {string} estring the string to be escaped
*/
function escapeChat(estring) {
return estring
.replace(/#/g, "")
.replace(/&/g, "")
.replace(/%/g, "")
.replace(/\$/g, "");
}
/**
* Unescapes a string to AO1 escape codes.
* @param {string} estring the string to be unescaped
*/
function unescapeChat(estring) {
return estring
.replace(//g, "#")
.replace(//g, "&")
.replace(//g, "%")
.replace(//g, "$");
}
/**
* Encode text on client side.
* @param {string} estring the string to be encoded
*/
function encodeChat(estring) {
const selectedEncoding = document.getElementById("client_encoding").value;
if (selectedEncoding === "unicode") {
// This approach works by escaping all special characters to Unicode escape sequences.
// Source: https://gist.github.com/mathiasbynens/1243213
return estring.replace(/[^\0-~]/g, function (ch) {
return "\\u" + ("000" + ch.charCodeAt().toString(16)).slice(-4);
});
} else if (selectedEncoding === "utf16") {
// Source: https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
const buffer = new ArrayBuffer(estring.length * 2);
const result = new Uint16Array(buffer);
for (let i = 0, strLen = estring.length; i < strLen; i++) {
result[i] = estring.charCodeAt(i);
}
return String(result);
} else {
return estring;
}
}
/**
* Decodes text on client side.
* @param {string} estring the string to be decoded
*/
function decodeChat(estring) {
const selectedDecoding = document.getElementById("client_decoding").value;
if (selectedDecoding === "unicode") {
// Source: https://stackoverflow.com/questions/7885096/how-do-i-decode-a-string-with-escaped-unicode
return estring.replace(/\\u([\d\w]{1,})/gi, function (match, group) {
return String.fromCharCode(parseInt(group, 16));
});
} else if (selectedDecoding === "utf16") {
// Source: https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
return String.fromCharCode.apply(null, new Uint16Array(estring.split(",")));
} else {
return estring;
}
}
/**
* Decoding text on client side.
* @param {string} estring the string to be decoded
*/
function decodeBBCode(estring) {
return estring
.replace(/\\n/g, "
") // Newline \n
.replace(/\[(\/?)b\]/g, "<$1b>") // Bold [b][/b]
.replace(/\[(\/?)i\]/g, "<$1i>") // Italic [i][/i]
.replace(/\[(\/?)s\]/g, "<$1del>") // Strikethrough [s][/s]
.replace(/\[(\/?)u\]/g, "<$1u>") // Underline [u][/u]
.replace(/\[(\/?)sub\]/g, "<$1sub>") // Subscript [sub][/sub]
.replace(/\[(\/?)sup\]/g, "<$1sup>") // Superscript [sup][/sup]
.replace(/\[m=([#a-zA-Z0-9]+)\]/g, "") // Markup [m=#0ff]
.replace(/\[(\/?)m\]/g, "<$1m>") // [m][/m]
.replace(/\[c=?([#a-zA-Z0-9]+)\]/g, "") // Color [c=red]
.replace(/\[\/c\]/g, ""); // [/c]
}
//
// Client code
//
let client = new Client(serverIP);
let viewport = new Viewport();
$(document).ready(function () {
client.initialObservBBCode();
client.loadResources();
});
// Create dialog and link to button
$(function () {
$("#callmod_dialog").dialog({
autoOpen: false,
resizable: false,
show: {
effect: "drop",
direction: "down",
duration: 500
},
hide: {
effect: "drop",
direction: "down",
duration: 500
},
height: "auto",
width: 400,
modal: true,
buttons: {
Sure: function () {
const reason = prompt("Please enter the reason", "");
client.sendZZ(reason);
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
});