view rlgterm.js @ 176:bf518a00190b

Add client-side support for watching dgamelaunch games.
author John "Elwin" Edwards
date Wed, 14 Jan 2015 07:44:22 -0500
parents 0f6da35b27a0
children 674e8899703b
line wrap: on
line source

/* rlgterm.js: Roguelike Gallery's driver for termemu.js */

/* Data on the available games. */
var games = {
  "rogue3": {
    "name": "Rogue V3",
    "uname": "rogue3"
  },
  "rogue4": {
    "name": "Rogue V4",
    "uname": "rogue4"
  },
  "rogue5": {
    "name": "Rogue V5",
    "uname": "rogue5"
  },
  "srogue": {
    "name": "Super-Rogue",
    "uname": "srogue"
  },
  "arogue5": {
    "name": "Advanced Rogue 5",
    "uname": "arogue5"
  }
};

var session = {
  /* The session id assigned by the server. */
  connect: false,
  /* Login name and key are now in sessionStorage. */
  /* Whether the game is being played or just watched. */
  playing: false,
  /* WebSocket for communication */
  sock: null
};

/* The interval ID for checking the status of current games. */
var statInterval = null;
/* How frequently to check. */
var statDelta = 8000;
/* A WebSocket to listen for status events. */
var statsock = null;
/* List of currently active games. */
var currentList = [];
/* Last time the list was updated. */
var currentTS = null;

function writeData(hexstr) {
  var codenum;
  var codes = [];
  var nc;
  var u8wait = 0; /* Stores bits from previous bytes of multibyte sequences. */
  var expect = 0; /* The number of 10------ bytes expected. */
  /* UTF-8 translation. */
  for (var i = 0; i < hexstr.length; i += 2) {
    nc = Number("0x" + hexstr.substr(i, 2));
    if (nc < 0x7F) {
      /* 0------- */
      codes.push(nc);
      /* Any incomplete sequence will be discarded. */
      u8wait = 0;
      expect = 0;
    }
    else if (nc < 0xC0) {
      /* 10------ : part of a multibyte sequence */
      if (expect > 0) {
        u8wait <<= 6;
        u8wait += (nc & 0x3F);
        expect--;
        if (expect == 0) {
          codes.push(u8wait);
          u8wait = 0;
        }
      }
      else {
        /* Assume an initial byte was missed. */
        u8wait = 0;
      }
    }
    /* These will all discard any incomplete sequence. */
    else if (nc < 0xE0) {
      /* 110----- : introduces 2-byte sequence */
      u8wait = (nc & 0x1F);
      expect = 1;
    }
    else if (nc < 0xF0) {
      /* 1110---- : introduces 3-byte sequence */
      u8wait = (nc & 0x0F);
      expect = 2;
    }
    else if (nc < 0xF8) {
      /* 11110--- : introduces 4-byte sequence */
      u8wait = (nc & 0x07);
      expect = 3;
    }
    else if (nc < 0xFC) {
      /* 111110-- : introduces 5-byte sequence */
      u8wait = (nc & 0x03);
      expect = 4;
    }
    else if (nc < 0xFE) {
      /* 1111110- : introduces 6-byte sequence */
      u8wait = (nc & 0x01);
      expect = 5;
    }
    else {
      /* 1111111- : should never appear */
      u8wait = 0;
      expect = 0;
    }
    /* Supporting all 31 bits is probably overkill... */
  }
  termemu.write(codes);
  return;
}

function errHandler() {
  message("Unable to connect to the server.", "warn");
}

function sendback(str) {
  /* For responding to terminal queries. */
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": str}));
  }
  return;
}

function sendkey(ev) {
  if (!session.playing)
    return;
  var keynum = ev.keyCode;
  var code;
  if (keynum >= 65 && keynum <= 90) {
    /* Letters. */
    if (ev.ctrlKey)
      keynum -= 64;
    else if (!ev.shiftKey)
      keynum += 32;
    code = keynum.toString(16);
    if (code.length < 2)
      code = "0" + code;
  }
  else if (keynum >= 48 && keynum <= 57) {
    /* The number row, NOT the numpad. */
    if (ev.shiftKey) {
      code = numShifts[keynum - 48].toString(16);
    }
    else {
      code = keynum.toString(16);
    }
  }
  else if (keynum in keyHexCodes) {
    if (ev.shiftKey)
      code = keyHexCodes[keynum][1];
    else
      code = keyHexCodes[keynum][0];
  }
  else if (keynum >= 16 && keynum <= 20) {
    /* Shift, Cntl, Alt, CAPSLOCK */
    return;
  }
  else {
    debug(1, "Ignoring keycode " + keynum);
    return;
  }
  ev.preventDefault();
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": code}));
  }
  /* Otherwise it is disconnected */
  return;
}

var charshifts = { '-': "5f", '=': "2b", '[': "7b", ']': "7d", '\\': "7c",
  ';': "3a", '\'': "22", ',': "3c", '.': "3e", '/': "3f", '`': "7e"
}

var kpkeys = { "KP1": "1b4f46", "KP2": "1b4f42", "KP3": "1b5b367e", 
               "KP4": "1b4f44", "KP5": "1b5b45", "KP6": "1b4f43", 
               "KP7": "1b4f48", "KP8": "1b4f41", "KP9": "1b5b357e" };

function vkey(c) {
  if (!session.playing)
    return;
  var keystr;
  if (c.match(/^[a-z]$/)) {
    if (termemu.ctrlp()) {
      var n = c.charCodeAt(0) - 96;
      keystr = n.toString(16);
      if (keystr.length < 2)
        keystr = "0" + keystr;
    }
    else if (termemu.shiftp())
      keystr = c.toUpperCase().charCodeAt(0).toString(16);
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c.match(/^[0-9]$/)) {
    if (termemu.shiftp())
      keystr = numShifts[c.charCodeAt(0) - 48].toString(16);
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c == '\n')
    keystr = "0a";
  else if (c == '\t')
    keystr = "09";
  else if (c == '\b')
    keystr = "08";
  else if (c == ' ')
    keystr = "20";
  else if (c in charshifts) {
    if (termemu.shiftp())
      keystr = charshifts[c];
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c in kpkeys) {
    keystr = kpkeys[c];
  }
  else
    return;
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": keystr}));
  }
  return;
}

function setup() {
  keyHexCodes.init();
  termemu.init("termwrap", 24, 80);
  /* Is someone already logged in? */
  if ("lcred" in sessionStorage) {
    setmode("choose");
    message("You are logged in as " + sessionStorage.getItem("lname") + ".");
  }
  else
    setmode("login");
  /* Set up the text size. */
  var cssSize = termemu.view.style.fontSize;
  var match = cssSize.match(/\d*/);
  if (!match) {
    return;
  }
  var csize = Number(match[0]);
  var allscreen = document.getElementById("termwrap");
  while (csize > 9 && csize < 48) {
    if (allscreen.scrollWidth * 1.2 > window.innerWidth) {
      csize = textsize(false);
    }
    else if (allscreen.scrollWidth * 2 < window.innerWidth) {
      csize = textsize(true);
    }
    else
      break;
  }
  if (!window.WebSocket) {
    message("Your browser does not support WebSockets. " +
            "This Web app will not work.", "warn");
  }
  return;
}

function toggleshift() {
  termemu.toggleshift();
  keydiv = document.getElementById("shiftkey");
  if (termemu.shiftp())
    keydiv.className = "keysel";
  else
    keydiv.className = "key";
  return;
}

function togglectrl() {
  termemu.togglectrl();
  keydiv = document.getElementById("ctrlkey");
  if (termemu.ctrlp())
    keydiv.className = "keysel";
  else
    keydiv.className = "key";
  return;
}

function formlogin(ev) {
  ev.preventDefault();
  /* What to do if logged in already? */
  var loginmsg = {};
  loginmsg["name"] = document.getElementById("input_name").value;
  loginmsg["pw"] = document.getElementById("input_pw").value;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'l') {
      /* Success */
      sessionStorage.setItem("lcred", reply.k);
      sessionStorage.setItem("lname", reply.u);
      message("You are now logged in as " + reply.u + ".");
      setmode("choose");
    }
    else if (reply.t == 'E') {
      var failmsg = "Logging in failed. ";
      if (reply.c == 2)
        failmsg += reply.s.match(/Invalid data: (.*)/)[1];
      else if (reply.c == 3)
        failmsg += "The username or password was incorrect.";
      else if (reply.c == 6)
        failmsg += "The server is shutting down.";
      message(failmsg, "warn");
      document.getElementById("input_name").value = "";
      document.getElementById("input_pw").value = "";
    }
  };
  req.open('POST', '/login', true);
  req.send(JSON.stringify(loginmsg));
  return;
}

function tableCurrent(gamelist) {
  var gamediv = document.getElementById("gametable");
  while (gamediv.children.length > 2)
    gamediv.removeChild(gamediv.children[2]);
  if (gamelist.length === 0) {
    gamediv.style.display = "none";
    document.getElementById("nogames").style.display = "block";
  }
  else {
    gamediv.style.display = "table";
    document.getElementById("nogames").style.display = "none";
  }
  for (var i = 0; i < gamelist.length; i++) {
    var row = document.createElement("div");
    var cell1 = document.createElement("div");
    var cell2 = document.createElement("div");
    var cell3 = document.createElement("div");
    var cell4 = document.createElement("div");
    var cell5 = document.createElement("div");
    cell1.appendChild(document.createTextNode(gamelist[i].p));
    var uname = gamelist[i].g;
    if (uname in games)
      cell2.appendChild(document.createTextNode(games[uname].name));
    else {
      continue;
    }
    var srcstr = "VR";
    if (gamelist[i].c == "rlg")
      srcstr = "Web";
    else if (gamelist[i].c == "dgl")
      srcstr = "SSH";
    cell3.appendChild(document.createTextNode(srcstr));
    cell4.appendChild(document.createTextNode(idlestr(gamelist[i].i)));
    var button = document.createElement("span");
    button.appendChild(document.createTextNode("Watch"));
    button.onclick = makeWatcher(uname + "/" + gamelist[i].p);
    button.className = "ibutton";
    cell5.appendChild(button);
    row.appendChild(cell1);
    row.appendChild(cell2);
    row.appendChild(cell3);
    row.appendChild(cell4);
    row.appendChild(cell5);
    gamediv.appendChild(row);
  }
}

/* Handles the status socket, opening and closing it when necessary. */
function wsCurrent() {
  if (!window.WebSocket)
    return;
  if (session.connect) {
    /* Don't bother with status if already playing/watching. */
    if (statsock) {
      statsock.close();
      statsock = null;
    }
    return;
  }
  if ("lcred" in sessionStorage) {
    /* When starting the socket, the choices list might not be initialized. */
    getchoices();
  }
  if (statsock)
    return; 
  statsock = new WebSocket("ws://" + window.location.host + "/status");
  statsock.onmessage = function (ev) {
    var msg;
    try {
      msg = JSON.parse(ev.data);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (msg.t == "t") {
      currentList = msg.g;
      currentTS = new Date();
      tableCurrent(currentList);
    }
    else if (msg.t == "p") {
      var now = new Date();
      var idletimes = {};
      for (var i = 0; i < msg.g.length; i++) {
        var tag = msg.g[i].g + "/" + msg.g[i].p;
        idletimes[tag] = msg.g[i].i;
      }
      for (var i = 0; i < currentList.length; i++) {
        var tag = currentList[i].g + "/" + currentList[i].p;
        if (tag in idletimes) {
          currentList[i].i = idletimes[tag];
        }
        else {
          currentList[i].i += now - currentTS;
        }
      }
      currentTS = now;
      tableCurrent(currentList);
    }
    else if (msg.t == "b") {
      var justbegun = {};
      justbegun.g = msg.g;
      justbegun.p = msg.p;
      justbegun.c = msg.c;
      justbegun.i = 0;
      currentList.push(justbegun);
      tableCurrent(currentList);
      if (msg.p == sessionStorage.getItem("lname")) {
        getchoices();
      }
    }
    else if (msg.t == "e") {
      var i = 0;
      while (i < currentList.length) {
        if (currentList[i].g == msg.g && currentList[i].p == msg.p)
          break;
        i++;
      }
      if (i < currentList.length) {
        currentList.splice(i, 1);
      }
      tableCurrent(currentList);
      if (msg.p == sessionStorage.getItem("lname")) {
        getchoices();
      }
    }
  };
  statsock.onclose = function (ev) {
    statsock = null;
  }
}

/* FIXME gamelist API has changed */
function getcurrent(clear) {
  if (window.WebSocket) {
    return;
  }
  if (session.connect || clear) {
    if (statInterval) {
      window.clearInterval(statInterval);
      statInterval = null;
    }
    return;
  }
  if (!statInterval) {
    statInterval = window.setInterval(getcurrent, statDelta);
  }
  if ("lcred" in sessionStorage)
    getchoices();
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply;
    try {
      reply = JSON.parse(req.responseText);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (!reply.s) {
      return;
    }
    tableCurrent(reply.g);
  };
  req.open('GET', '/status', true);
  req.send();
  return;
}

function getchoices() {
  if (session.connect || !("lcred" in sessionStorage))
    return;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply;
    try {
      reply = JSON.parse(req.responseText);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (!("name" in reply) || reply["name"] != sessionStorage.getItem("lname") 
          || !("stat" in reply))
      return;
    var optdiv = document.getElementById("opttable");
    /* Don't remove the first child, it's the header. */
    while (optdiv.childNodes.length > 1)
      optdiv.removeChild(optdiv.childNodes[1]);
    for (var gname in games) {
      if (!(gname in reply.stat))
        continue;
      var acttext;
      if (reply.stat[gname] == "s")
        acttext = "Resume your game";
      else if (reply.stat[gname] == "0")
        acttext = "Start a game";
      else if (reply.stat[gname] == "p" || reply.stat[gname] == "d")
        acttext = "Force save";
      else
        continue;
      var button = document.createElement("span");
      button.appendChild(document.createTextNode(acttext));
      if ("s0".indexOf(reply.stat[gname]) >= 0) {
        button.onclick = makeStarter(gname);
        button.className = "ibutton";
      }
      else {
        button.onclick = makeStopper(gname);
        button.className = "ibutton";
      }
      var actdiv = document.createElement("div");
      actdiv.appendChild(button);
      var gamediv = document.createElement("div");
      gamediv.appendChild(document.createTextNode(games[gname].name));
      var rowdiv = document.createElement("div");
      rowdiv.appendChild(gamediv);
      rowdiv.appendChild(actdiv);
      optdiv.appendChild(rowdiv);
    }
  };
  req.open('GET', '/pstatus/' + sessionStorage.getItem("lname"), true);
  req.send();
  return;
}

/* This can't be in the loop in getchoices(), or the closure's scope will
 * get overwritten on the next iteration, and then all the games end up 
 * being Super-Rogue.
 */
function makeStarter(gname) {
  if (!(gname in games))
    return null;
  var game = games[gname];
  function starter(ev) {
    startgame(game);
  }
  return starter;
}

function makeStopper(gname) {
  if (!(gname in games))
    return null;
  var game = games[gname];
  function stopper(ev) {
    stopgame(game);
  }
  return stopper;
}

function stopgame(game) {
  if (!("lcred" in sessionStorage))
    return;
  var stopmsg = {"key": sessionStorage.getItem("lcred"), "g": game.uname};
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'E') {
      if (reply.c == 7)
        message("That game has already stopped.");
      else if (reply.c == 1) {
        logout();
        message("The server forgot about you, please log in again.", "warn");
      }
      else {
        message("That game could not be stopped because: " + reply.s + 
                "This might be a bug.", "warn");
      }
    }
  }
  req.open('POST', '/quit', true);
  req.send(JSON.stringify(stopmsg));
  return;
}

function startgame(game) {
  if (!("lcred" in sessionStorage) || session.connect)
    return;
  if (!window.WebSocket) {
    return;
  }
  var sockurl = "ws://" + window.location.host + "/play/" + game.uname;
  sockurl += "?key=" + sessionStorage.getItem("lcred") + "&w=80&h=24";
  ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.connect = true;
    session.playing = true;
    session.sock = ws;
    setmode("play");
  };
  ws.onmessage = function (event) {
    var msgObject = JSON.parse(event.data);
    if (msgObject.t == 's') {
      termemu.resize(msgObject.h, msgObject.w);
      message("You are now playing " + games[msgObject.g].name + ".");
    }
    else if (msgObject.t == 'd') {
      writeData(msgObject.d);
    }
  };
  ws.onclose = function (event) {
    session.sock = null;
    gameover();
  };
}

function makeWatcher(t) {
  function watcher(ev) {
    startwatching(t);
  }
  return watcher;
}

function startwatching(tag) {
  if (session.connect)
    return;
  var sockurl = "ws://" + window.location.host + "/watch/" + tag;
  var ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.connect = true;
    session.sock = ws;
    setmode("watch");
  };
  ws.onmessage = function (event) {
    var msgObject = JSON.parse(event.data);
    if (msgObject.t == 'w') {
      termemu.resize(msgObject.h, msgObject.w);
      termemu.reset();
      termemu.toAltBuf();
      var pname = msgObject.p;
      var gname = games[msgObject.g].name;
      message("You are now watching " + pname + " play " + gname + ".");
    }
    else if (msgObject.t == 'd') {
      writeData(msgObject.d);
    }
  };
  ws.onclose = function (event) {
    session.sock = null;
    gameover();
  };
}

function formreg(ev) {
  ev.preventDefault();
  /* This ought to check for being logged in instead. */
  if (session.connect)
    return;
  var regmsg = {};
  regmsg["name"] = document.getElementById("regin_name").value;
  regmsg["pw"] = document.getElementById("regin_pw").value;
  regmsg["email"] = document.getElementById("regin_email").value;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'r') {
      /* Success */
      message("Welcome " + reply.u + ", you are now registered.");
      sessionStorage.setItem("lcred", reply.k);
      sessionStorage.setItem("lname", reply.u);
      message("You are now logged in as " + reply.u + ".");
      setmode("choose");
    }
    else if (reply.t == 'E') {
      var failmsg = "Registration failed.";
      if (reply.c == 2) {
        var errdesc = reply.s.match(/Invalid data: (.*)/)[1];
        if (errdesc.match(/No name/))
          failmsg += " You need to choose a name.";
        else if (errdesc.match(/No password/))
          failmsg += " You need to choose a password.";
        else if (errdesc.match(/Invalid/)) {
          failmsg += " Names must be letters and numbers. E-mail addresses " +
                     "can also contain these characters: @.-_";
        }
        else if (errdesc.match(/Username/))
          failmsg += " Someone else is already using that name.";
        else
          failmsg += " This is probably a bug.";
      }
      else
        failmsg += " This is probably a bug.";
      message(failmsg, "warn");
      document.getElementById("regin_name").value = "";
      document.getElementById("regin_pw").value = "";
      document.getElementById("regin_email").value = "";
    }
  };
  req.open('POST', '/addacct', true);
  req.send(JSON.stringify(regmsg));
  return;
}

function gameover() {
  if (!session.connect)
    return;
  /* TODO IFACE2 If the end was unexpected, tell player the game was saved. */
  if (session.playing)
    message("Finished playing.");
  else
    message("Finished watching.");
  session.connect = false;
  session.playing = false;
  termemu.toNormBuf();
  if ("lcred" in sessionStorage)
    setmode("choose");
  else
    setmode("login");
  return;
}

function logout() {
  sessionStorage.removeItem("lcred");
  sessionStorage.removeItem("lname");
  setmode("login");
}

/* TODO determine whether this is needed */
function stop() {
  if (!session.connect)
    return;
  if (session.sock) {
    session.sock.close();
    return;
  }
}

function setmode(mode, ev) {
  if (ev)
    ev.preventDefault();
  if (mode == "play") {
    document.getElementById("keyboard").style.display = "block";
    document.getElementById("playctl").style.display = "block";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "none";
  }
  else if (mode == "watch") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "block";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "none";
  }
  else if (mode == "choose") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "block";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "block";
    getcurrent();
  }
  else if (mode == "login") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "block";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "block";
    getcurrent();
  }
  else if (mode == "register") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "block";
    document.getElementById("current").style.display = "none";
  }
  wsCurrent();
}

function toggleBlock(id) {
  var element = document.getElementById(id);
  if (!element)
    return;
  if (element.style.display != "block")
    element.style.display = "block";
  else
    element.style.display = "none";
}

function message(msg, mtype) {
  var msgdiv = document.createElement("div");
  var msgtext = document.createTextNode(msg);
  msgdiv.appendChild(msgtext);
  if (mtype) {
    msgdiv.className = mtype;
  }
  var msgcontainer = document.getElementById("messages");