From dfe6a18135bbaf19b749ab28b93b2d556b0eae0a Mon Sep 17 00:00:00 2001 From: Tor Andersson Date: Thu, 20 May 2021 12:56:11 +0200 Subject: crusader: Stuff. --- about.html | 17 +- cover.1x.jpg | Bin 0 -> 29817 bytes cover.2x.jpg | Bin 0 -> 97957 bytes cover.jpg | Bin 10299 -> 1240937 bytes data.js | 429 +++++++++++++++ info/blocks.html | 60 ++- info/notes.html | 11 +- play.html | 398 ++++++++++++++ rules.js | 1570 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ thumbnail.jpg | Bin 0 -> 17191 bytes ui.js | 718 +++++++++++++++++++++++++ 11 files changed, 3170 insertions(+), 33 deletions(-) create mode 100644 cover.1x.jpg create mode 100644 cover.2x.jpg create mode 100644 data.js create mode 100644 play.html create mode 100644 rules.js create mode 100644 thumbnail.jpg create mode 100644 ui.js diff --git a/about.html b/about.html index 1bfb01d..8fc834d 100644 --- a/about.html +++ b/about.html @@ -1,8 +1,23 @@

-Crusader Rex is a wargame of the 3rd Crusade. One player plays the Christian Franks, the other, the Muslim Saracens. The objective of the game is to control important Victory Cities such as Jerusalem, Acre, Damascus, and Antioch. +Crusader Rex is a wargame of the 3rd Crusade. One player plays the Christian +Franks, the other, the Muslim Saracens. The objective of the game is to control +important Victory Cities such as Jerusalem, Acre, Damascus, and Antioch. + +

Designer: Jerry Taylor and Tom Dalgliesh.

Publisher: Columbia Games. + +

+ +

+This game is in Beta testing. Please report bugs, errors, and glitches on +Github or the +Discord server. diff --git a/cover.1x.jpg b/cover.1x.jpg new file mode 100644 index 0000000..dbb23ef Binary files /dev/null and b/cover.1x.jpg differ diff --git a/cover.2x.jpg b/cover.2x.jpg new file mode 100644 index 0000000..3c059b8 Binary files /dev/null and b/cover.2x.jpg differ diff --git a/cover.jpg b/cover.jpg index f5f6cb2..e43a8fe 100644 Binary files a/cover.jpg and b/cover.jpg differ diff --git a/data.js b/data.js new file mode 100644 index 0000000..7b89d3f --- /dev/null +++ b/data.js @@ -0,0 +1,429 @@ +"use strict"; + +const VICTORY_TOWNS = [ + "Aleppo", "Damascus", "Egypt", + "Antioch", "Tripoli", "Acre", "Jerusalem" +]; + +const CARDS = { + 1: { name: "Assassins", event: "assassins", image: "card_assassins" }, + 2: { name: "Guide", event: "guide", image: "card_guide" }, + 3: { name: "Intrigue", event: "intrigue", image: "card_intrigue" }, + 4: { name: "Jihad", event: "jihad", image: "card_jihad" }, + 5: { name: "Manna", event: "manna", image: "card_manna" }, + 6: { name: "Winter Campaign", event: "winter_campaign", image: "card_winter_campaign" }, + 7: { name: "3", moves: 3, image: "card_3" }, + 8: { name: "3", moves: 3, image: "card_3" }, + 9: { name: "3", moves: 3, image: "card_3" }, + 10: { name: "3", moves: 3, image: "card_3" }, + 11: { name: "3", moves: 3, image: "card_3" }, + 12: { name: "3", moves: 3, image: "card_3" }, + 13: { name: "2", moves: 2, image: "card_2" }, + 14: { name: "2", moves: 2, image: "card_2" }, + 15: { name: "2", moves: 2, image: "card_2" }, + 16: { name: "2", moves: 2, image: "card_2" }, + 17: { name: "2", moves: 2, image: "card_2" }, + 18: { name: "2", moves: 2, image: "card_2" }, + 19: { name: "2", moves: 2, image: "card_2" }, + 20: { name: "2", moves: 2, image: "card_2" }, + 21: { name: "2", moves: 2, image: "card_2" }, + 22: { name: "1", moves: 1, image: "card_1" }, + 23: { name: "1", moves: 1, image: "card_1" }, + 24: { name: "1", moves: 1, image: "card_1" }, + 25: { name: "1", moves: 1, image: "card_1" }, + 26: { name: "1", moves: 1, image: "card_1" }, + 27: { name: "1", moves: 1, image: "card_1" }, +}; + +const BLOCKS = {}; +const ROADS = {}; + +// From edit.html output +const TOWNS = { + "Acre":{"x":452,"y":1566}, + "Ajlun":{"x":987,"y":1542}, + "Albara":{"x":810,"y":388}, + "Aleppo":{"x":1051,"y":108}, + "Amman":{"x":1088,"y":1838}, + "Anjar":{"x":753,"y":1129}, + "Antioch":{"x":471,"y":189}, + "Artah":{"x":865,"y":149}, + "Ascalon":{"x":367,"y":2081}, + "Ashtera":{"x":1038,"y":1419}, + "Baalbek":{"x":842,"y":1008}, + "Baisan":{"x":707,"y":1685}, + "Banyas":{"x":764,"y":1362}, + "Beaufort":{"x":605,"y":1354}, + "Beersheba":{"x":444,"y":2283}, + "Beirut":{"x":527,"y":1137}, + "Botron":{"x":540,"y":991}, + "Caesarea":{"x":402,"y":1754}, + "Damascus":{"x":1059,"y":1185}, + "Damiya":{"x":847,"y":1808}, + "Dimona":{"x":630,"y":2294}, + "Egypt":{"x":202,"y":2318}, + "Gaza":{"x":300,"y":2183}, + "Hama":{"x":1035,"y":477}, + "Harim":{"x":699,"y":124}, + "Hebron":{"x":680,"y":2109}, + "Homs":{"x":1053,"y":683}, + "Jaffa":{"x":399,"y":1923}, + "Jericho":{"x":836,"y":1931}, + "Jerusalem":{"x":680,"y":1980}, + "Kassab":{"x":426,"y":339}, + "Kerak":{"x":1008,"y":2076}, + "Krak":{"x":774,"y":726}, + "Lachish":{"x":495,"y":2148}, + "Lacum":{"x":919,"y":885}, + "Latakia":{"x":401,"y":445}, + "Legio":{"x":587,"y":1658}, + "Margat":{"x":540,"y":567}, + "Masyaf":{"x":758,"y":604}, + "Monterrand":{"x":920,"y":603}, + "Nablus":{"x":643,"y":1787}, + "Qaddas":{"x":1145,"y":916}, + "Ramallah":{"x":514,"y":1952}, + "Saone":{"x":653,"y":428}, + "Shughur":{"x":656,"y":296}, + "Sidon":{"x":493,"y":1276}, + "St. Simeon":{"x":364,"y":211}, + "Tartus":{"x":605,"y":718}, + "Tiberias":{"x":699,"y":1560}, + "Tripoli":{"x":621,"y":882}, + "Tyre":{"x":465,"y":1397}, + "Zerdana":{"x":1021,"y":300}, + "Zoar":{"x":955,"y":2278}, + /* + "Germania1":{"x":139,"y":273}, + "Germania2":{"x":139,"y":359}, + "Germania3":{"x":138,"y":447}, + "France1":{"x":140,"y":573}, + "France2":{"x":140,"y":660}, + "France3":{"x":139,"y":747}, + "England1":{"x":139,"y":873}, + "England2":{"x":140,"y":961}, + "England3":{"x":138,"y":1047}, + "F. Pool":{"x":120,"y":2150}, + "S. Pool":{"x":120,"y":2150}, + */ + "Germania":{"x":139,"y":273}, + "France":{"x":140,"y":573}, + "England":{"x":139,"y":873}, + "F. Pool":{"x":120,"y":2150}, + "S. Pool":{"x":120,"y":2150}, +}; + +const PORTS = []; + +(function () { + let r = 1, c = 1; + + let nomads = { Arabs: 1, Turks: 1, Kurds: 1 } + + function army(rc, owner, name, home, move, steps, combat, order) { + let id = name; + if (order == 'Military Orders' || order == 'Pilgrims' || order == 'Turcopoles') + id = home + " " + name; + if (order == 'Nomads') + id += " " + nomads[name]++; + if (name == 'Reynald' || name == 'Raymond') + id += " " + home; + if (id in BLOCKS) + throw Error("Name clash: " + id + " order:"+order + " " + JSON.stringify(nomads)); + BLOCKS[id] = { + owner: owner, + name: name, + type: order.toLowerCase().replace(/ /g, "_"), + home: home, + move: move, + steps: steps, + combat: combat, + image: rc, + } + } + + function frank(rc, name, home, move, steps, combat, order) { + army(rc, "Frank", name, home, move, steps, combat, order); + } + function saracen(rc, name, home, move, steps, combat, order) { + army(rc, "Saracen", name, home, move, steps, combat, order); + } + + frank(13, "Barbarossa", "Germania", 2, 4, "B3", "Crusaders"); + frank(23, "Frederik", "Germania", 2, 3, "B2", "Crusaders"); + frank(33, "Leopold", "Germania", 2, 3, "B3", "Crusaders"); + + frank(11, "Richard", "England", 3, 4, "B4", "Crusaders"); + frank(21, "Robert", "England", 2, 3, "B3", "Crusaders"); + frank(31, "Crossbows", "Aquitaine", 2, 3, "A2", "Crusaders"); + + frank(12, "Philippe", "France", 2, 4, "B3", "Crusaders"); + frank(22, "Hugues", "Bourgogne", 2, 4, "B2", "Crusaders"); + frank(32, "Fileps", "Flanders", 2, 3, "B3", "Crusaders"); + + frank(42, "Pilgrims", "Genoa", 2, 4, "C2", "Pilgrims"); + frank(43, "Pilgrims", "Sicily", 2, 3, "C2", "Pilgrims"); + frank(52, "Pilgrims", "Brittany", 2, 4, "C2", "Pilgrims"); + + frank(14, "Templars", "Jerusalem", 3, 3, "B3", "Military Orders"); + frank(15, "Templars", "Antioch", 3, 3, "B3", "Military Orders"); + frank(16, "Templars", "Gaza", 3, 3, "B3", "Military Orders"); + frank(17, "Templars", "Tartus", 3, 3, "B3", "Military Orders"); + frank(24, "Hospitallers", "Jerusalem", 3, 4, "B3", "Military Orders"); + frank(25, "Hospitallers", "Acre", 3, 3, "B3", "Military Orders"); + frank(26, "Hospitallers", "Krak", 3, 2, "B3", "Military Orders"); + + frank(27, "Reynald", "Sidon", 2, 3, "B2", "Outremers"); + frank(34, "Conrad", "Tyre", 2, 4, "B3", "Outremers"); + frank(35, "Balian", "Nablus", 2, 3, "B2", "Outremers"); + frank(36, "Walter", "Caesarea", 2, 3, "B2", "Outremers"); + frank(37, "Raymond", "Tiberias", 2, 3, "B2", "Outremers"); + frank(44, "King Guy", "Jerusalem", 2, 4, "B2", "Outremers"); + frank(45, "Reynald", "Kerak", 3, 2, "B3", "Outremers"); + frank(46, "Bohemond", "Antioch", 2, 4, "B2", "Outremers"); + frank(47, "Raymond", "Tripoli", 2, 4, "B2", "Outremers"); + frank(53, "Josselin", "Saone", 2, 3, "B2", "Outremers"); + + frank(41, "Turcopole", "Antioch", 3, 3, "A2", "Turcopoles"); + frank(51, "Turcopole", "Beirut", 3, 3, "A2", "Turcopoles"); + + army(54, "Assassins", "Assassins", "Masyaf", 0, 3, "A3", "Assassins"); + + saracen(55, "Qara-Qush", "Egypt", 3, 3, "B3", "Emirs"); + saracen(56, "Zangi", "Aleppo", 3, 3, "B2", "Emirs"); + saracen(57, "Sanjar", "Aleppo", 3, 3, "B2", "Emirs"); + + saracen(61, "Yazkuj", "Ashtera", 3, 2, "B2", "Emirs"); + saracen(62, "Sulaiman", "Artah", 3, 2, "B2", "Emirs"); + saracen(63, "Keukburi", "Damascus", 3, 3, "B3", "Emirs"); + saracen(64, "Shirkuh", "Homs", 3, 3, "B2", "Emirs"); + saracen(65, "Jurdik", "Zerdana", 3, 3, "B2", "Emirs"); + saracen(66, "Bahram", "Baalbek", 3, 3, "B2", "Emirs"); + saracen(67, "Tuman", "Homs", 3, 3, "B3", "Emirs"); + + saracen(71, "Taqi al Din", "Hama", 3, 4, "A2", "Emirs"); + saracen(72, "Al Mashtub", "Damascus", 3, 4, "B3", "Emirs"); + saracen(73, "Al Adil", "Egypt", 3, 4, "A2", "Emirs"); + saracen(74, "Saladin", "Damascus", 3, 4, "A3", "Emirs"); + saracen(75, "Al Aziz", "Egypt", 3, 3, "B2", "Emirs"); + saracen(76, "Al Afdal", "Damascus", 3, 3, "B3", "Emirs"); + saracen(77, "Al Zahir", "Aleppo", 3, 3, "A2", "Emirs"); + + saracen(81, "Yuzpah", "Egypt", 3, 4, "B2", "Emirs"); + saracen(82, "Qaimaz", "Banyas", 3, 3, "B2", "Emirs"); + + saracen(83, "Kurds", "Damascus", 3, 4, "C1", "Nomads"); + saracen(84, "Kurds", "Damascus", 3, 4, "C1", "Nomads"); + saracen(85, "Kurds", "Damascus", 3, 3, "C2", "Nomads"); + saracen(86, "Kurds", "Damascus", 3, 3, "C2", "Nomads"); + + saracen(91, "Turks", "Aleppo", 3, 3, "A2", "Nomads"); + saracen(92, "Turks", "Aleppo", 3, 3, "A2", "Nomads"); + saracen(93, "Turks", "Aleppo", 3, 4, "A1", "Nomads"); + saracen(94, "Turks", "Aleppo", 3, 4, "A1", "Nomads"); + + saracen(95, "Arabs", "Egypt", 3, 3, "B2", "Nomads"); + saracen(96, "Arabs", "Egypt", 3, 3, "B2", "Nomads"); + saracen(97, "Arabs", "Egypt", 3, 4, "B1", "Nomads"); + saracen(87, "Arabs", "Egypt", 3, 4, "B1", "Nomads"); + + function town(axis, major, minor, wrap, region, name, rating, type) { + TOWNS[name].region = region; + TOWNS[name].rating = rating; + if (type == 'port' || type == 'fortified-port') + TOWNS[name].port = true; + if (type == 'fortified-port') + TOWNS[name].fortified_port = true; + if (TOWNS[name].port) + PORTS.push(name); + TOWNS[name].exits = []; + TOWNS[name].layout_axis = axis; + TOWNS[name].layout_major = (1 - major) / 2; + TOWNS[name].layout_minor = (1 - minor) / 2; + TOWNS[name].wrap = wrap ? wrap : Math.max(2, rating); + } + +/* + town('X', 0, 0, 0, "Staging", "England1", 0, "staging"); + town('X', 0, 0, 0, "Staging", "England2", 0, "staging"); + town('X', 0, 0, 0, "Staging", "England3", 0, "staging"); + town('X', 0, 0, 0, "Staging", "France1", 0, "staging"); + town('X', 0, 0, 0, "Staging", "France2", 0, "staging"); + town('X', 0, 0, 0, "Staging", "France3", 0, "staging"); + town('X', 0, 0, 0, "Staging", "Germania1", 0, "staging"); + town('X', 0, 0, 0, "Staging", "Germania2", 0, "staging"); + town('X', 0, 0, 0, "Staging", "Germania3", 0, "staging"); +*/ + town('X', 0, 0, 0, "Staging", "England", 0, "staging", "S", 3); + town('X', 0, 0, 0, "Staging", "France", 0, "staging", "S", 3); + town('X', 0, 0, 0, "Staging", "Germania", 0, "staging", "S", 3); + + town('X', 0, 0, 0, "Pool", "F. Pool", 0, "pool", "N", 12); + town('X', 0, 0, 0, "Pool", "S. Pool", 0, "pool", "N", 12); + + town('X', 1, 0, 0, "Syria", "Aleppo", 3, "town", "E"); + town('Y', 0, 0, 0, "Syria", "Artah", 1, "town", "V"); + town('X', 1, 0, 0, "Syria", "Zerdana", 1, "town", "E"); + town('X', 1, 0, 0, "Syria", "Hama", 1, "town", "E"); + town('X', 1, 0, 0, "Syria", "Homs", 2, "town", "E"); + town('X', 0, 0, 0, "Syria", "Lacum", 0, "town", "H"); + town('X', 0, 0, 0, "Syria", "Qaddas", 0, "town", "H"); + town('X', 0, 0, 0, "Syria", "Baalbek", 1, "town", "H"); + town('X', 0, 0, 0, "Syria", "Anjar", 0, "town", "H"); + town('X', 0, 0, 0, "Syria", "Damascus", 4, "town", "H"); + town('X', 1, 0, 0, "Syria", "Banyas", 1, "town", "E"); + town('X', 1, 0, 0, "Syria", "Ashtera", 1, "town", "E"); + town('X', 1, 0, 0, "Syria", "Ajlun", 0, "town", "E"); + + town('X', 0, 0, 0, "Antioch", "St. Simeon", 0, "port", "W"); + town('Y', 0, 0, 0, "Antioch", "Antioch", 3, "town", "V"); + town('Y', 1, 0, 0, "Antioch", "Harim", 0, "town", "S"); + town('X', 0, 0, 0, "Antioch", "Kassab", 0, "town", "H"); + town('X', 0, 0, 0, "Antioch", "Shughur", 0, "town", "H"); + town('X', -1, 0, 0, "Antioch", "Latakia", 1, "port", "W"); + town('X', 0, 0, 0, "Antioch", "Saone", 1, "town", "H"); + town('Y', 0, 0, 0, "Antioch", "Albara", 0, "town", "V"); + town('X', -1, 0, 0, "Antioch", "Margat", 1, "port", "W"); + + town('X', 0, 0, 0, "Masyaf", "Masyaf", 1, "town", "H"); + + town('Y', 0, 0, 0, "Tripoli", "Monterrand", 0, "town", "V"); + town('X', -1, 0, 0, "Tripoli", "Tartus", 1, "port", "W"); + town('X', 1, 0, 0, "Tripoli", "Krak", 1, "town", "E"); + town('X', -1, 0, 0, "Tripoli", "Tripoli", 2, "fortified-port", "W"); + town('X', -1, 0, 0, "Tripoli", "Botron", 0, "town", "W"); + + town('X', -1, 0, 0, "Jerusalem", "Beirut", 2, "port", "W"); + town('X', -1, 0, 0, "Jerusalem", "Sidon", 1, "port", "W"); + town('X', -1, 0, 0, "Jerusalem", "Tyre", 2, "fortified-port", "W"); + town('Y', 0, 0, 0, "Jerusalem", "Beaufort", 1, "town", "V"); + town('X', -1, 0, 0, "Jerusalem", "Acre", 3, "port", "W"); + town('X', 1, 0, 0, "Jerusalem", "Tiberias", 2, "town", "E"); + town('Y', 1, 0, 0, "Jerusalem", "Legio", 0, "town", "N"); + town('X', 1, 0, 0, "Jerusalem", "Baisan", 1, "town", "E"); + town('X', -1, 0, 0, "Jerusalem", "Caesarea", 1, "port", "W"); + town('X', 0, 0, 0, "Jerusalem", "Nablus", 1, "town", "H"); + town('X', 0, 0, 0, "Jerusalem", "Damiya", 0, "town", "H"); + town('X', 0, 0, 0, "Jerusalem", "Amman", 1, "town", "H"); + town('X', -1, 0, 0, "Jerusalem", "Jaffa", 1, "port", "W"); + town('Y', 0, 0, 0, "Jerusalem", "Ramallah", 0, "town", "V"); + town('X', 0, 0, 0, "Jerusalem", "Jerusalem", 3, "town", "H"); + town('Y', 0, 0, 0, "Jerusalem", "Jericho", 0, "town", "V"); + town('X', -1, 0, 0, "Jerusalem", "Ascalon", 2, "port", "W"); + town('Y', 0, 0, 0, "Jerusalem", "Lachish", 0, "town", "V"); + town('X', 0, 0, 0, "Jerusalem", "Hebron", 1, "town", "H"); + town('X', 1, 0, 0, "Jerusalem", "Kerak", 1, "town", "E"); + town('X', 0, 0, 0, "Jerusalem", "Gaza", 1, "town", "H"); + town('Y', 0, 0, 0, "Jerusalem", "Beersheba", 0, "town", "V"); + town('X', 0, 0, 0, "Jerusalem", "Dimona", 0, "town", "H"); + town('X', 1, 0, 0, "Jerusalem", "Zoar", 0, "town", "E"); + + town('X', 0, 0, 0, "Egypt", "Egypt", 4, "port", "H"); + + function road(A,B,type) { + let id = (A < B) ? (A + "/" + B) : (B + "/" + A); + ROADS[id] = type; + TOWNS[A].exits.push(B); + TOWNS[B].exits.push(A); + } + + function major(A,B) { road(A,B,"major"); } + function minor(A,B) { road(A,B,"minor"); } + + major("Antioch", "Harim"); + major("Harim", "Artah"); + major("Artah", "Aleppo"); + major("Aleppo", "Zerdana"); + major("Zerdana", "Hama"); + major("Hama", "Albara"); + major("Hama", "Monterrand"); + major("Hama", "Homs"); + major("Albara", "Shughur"); + major("Shughur", "Harim"); + major("Monterrand", "Krak"); + major("Krak", "Homs"); + major("Krak", "Tripoli"); + major("Tripoli", "Tartus"); + major("Tripoli", "Botron"); + major("Tartus", "Margat"); + major("Margat", "Latakia"); + major("Botron", "Beirut"); + major("Beirut", "Sidon"); + major("Sidon", "Tyre"); + major("Tyre", "Beaufort"); + major("Beaufort", "Banyas"); + major("Banyas", "Damascus"); + major("Damascus", "Qaddas"); + major("Qaddas", "Homs"); + major("Homs", "Lacum"); + major("Lacum", "Baalbek"); + major("Baalbek", "Anjar"); + major("Anjar", "Beaufort"); + major("Damascus", "Ashtera"); + major("Ashtera", "Ajlun"); + major("Ajlun", "Amman"); + major("Amman", "Kerak"); + major("Kerak", "Zoar"); + major("Zoar", "Hebron"); + major("Hebron", "Jerusalem"); + major("Jerusalem", "Ramallah"); + major("Ramallah", "Jaffa"); + major("Jaffa", "Ascalon"); + major("Ascalon", "Gaza"); + major("Gaza", "Egypt"); + major("Ajlun", "Tiberias"); + major("Tiberias", "Acre"); + major("Acre", "Legio"); + major("Legio", "Baisan"); + major("Baisan", "Tiberias"); + major("Baisan", "Nablus"); + major("Nablus", "Legio"); + major("Nablus", "Jerusalem"); + major("Acre", "Caesarea"); + major("Caesarea", "Jaffa"); + + minor("St. Simeon", "Antioch"); + minor("Antioch", "Kassab"); + minor("Kassab", "Latakia"); + minor("Latakia", "Saone"); + minor("Saone", "Shughur"); + minor("Saone", "Albara"); + minor("Albara", "Zerdana"); + minor("Zerdana", "Artah"); + + minor("Monterrand", "Homs"); + + minor("Tartus", "Krak"); + minor("Krak", "Lacum"); + minor("Lacum", "Qaddas"); + minor("Tripoli", "Baalbek"); + minor("Beirut", "Anjar"); + minor("Anjar", "Damascus"); + minor("Sidon", "Beaufort"); + minor("Tiberias", "Banyas"); + minor("Banyas", "Ashtera"); + minor("Tyre", "Acre"); + minor("Caesarea", "Nablus"); + minor("Nablus", "Damiya"); + minor("Damiya", "Baisan"); + minor("Damiya", "Amman"); + minor("Amman", "Jericho"); + minor("Jericho", "Damiya"); + minor("Jericho", "Kerak"); + minor("Jericho", "Jerusalem"); + + minor("Ramallah", "Ascalon"); + minor("Ascalon", "Lachish"); + minor("Lachish", "Gaza"); + minor("Gaza", "Beersheba"); + minor("Beersheba", "Egypt"); + minor("Beersheba", "Dimona"); + minor("Dimona", "Zoar"); + minor("Dimona", "Hebron"); + minor("Hebron", "Lachish"); + + // TODO: seats and alternate seats +})(); + +if (typeof module != 'undefined') + module.exports = { CARDS, BLOCKS, TOWNS, PORTS, ROADS } diff --git a/info/blocks.html b/info/blocks.html index e3b75e4..178a8f8 100644 --- a/info/blocks.html +++ b/info/blocks.html @@ -30,84 +30,88 @@ div.block:hover { transform: scale(2); }

Crusader Rex - Blocks

- -

Frank

+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
- -

Saracen

+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
-
- -

Assassins

-
-
+
diff --git a/info/notes.html b/info/notes.html index 4a4e5f6..c1361cf 100644 --- a/info/notes.html +++ b/info/notes.html @@ -14,11 +14,14 @@ Crusader Rex: Implementation Notes

-How do I designate the attacking main force? +How do I designate the Main Attack?

-The first block that moves into a contested area defines the main force. Any other blocks -who enter the battle from the same road also count as being part of the main force. -The attacking main force move is marked with an asterisk in the game log. +The first block that moves into an enemy area defines the main attack road. +Any other blocks who enter the battle from the same road also count as being part of the main attack. +

+Likewise, the first block that moves to reinforce a battle started by player one defines the main reinforcement. +

+The main attack, main reinforcement, and secondary reinforcement moves are noted in the game log with *, †, and ‡ respectively.


diff --git a/play.html b/play.html new file mode 100644 index 0000000..dfc3c5a --- /dev/null +++ b/play.html @@ -0,0 +1,398 @@ + + + + + +CRUSADER REX + + + + + + + + + + + + +
+ +
+
Chat
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + +
+
+
+
+ +
$PROMPT
+ + + + + + + + + + + + +
+ +
+ +
+
+
Frank ($USER)
+
+
+ +
+
+
Saracen ($USER)
+
+
+ +
$TURN
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ diff --git a/rules.js b/rules.js new file mode 100644 index 0000000..3682c88 --- /dev/null +++ b/rules.js @@ -0,0 +1,1570 @@ +"use strict"; + +// TODO: frank seat adjustment at setup +// TODO: saladin seat adjustment at setup + +exports.scenarios = [ + "Rules 2.0", +]; + +const { CARDS, BLOCKS, TOWNS, PORTS, ROADS } = require('./data'); + +const FRANK = "Frank"; +const SARACEN = "Saracen"; +const ASSASSINS = "Assassins"; +const ENEMY = { Frank: "Saracen", Saracen: "Frank" }; +const OBSERVER = "Observer"; +const BOTH = "Both"; +const F_POOL = "F. Pool"; +const S_POOL = "S. Pool"; + +const HIT_TEXT = "\u2605"; +const MISS_TEXT = "\u2606"; + +const ATTACK_MARK = "*"; +const RESERVE_MARK_1 = "\u2020"; +const RESERVE_MARK_2 = "\u2021"; +const NO_MARK = ""; + +let states = {}; + +let game = null; + +function log(...args) { + let s = Array.from(args).join(""); + game.log.push(s); +} + +function logp(...args) { + let s = game.active + " " + Array.from(args).join(""); + game.log.push(s); +} + +function log_move_start(from) { + game.move_buf = [ from ]; +} + +function log_move_continue(to, mark) { + if (mark) + game.move_buf.push(to + mark); + else + game.move_buf.push(to); +} + +function log_move_end() { + if (game.move_buf && game.move_buf.length > 1) + game.turn_log.push(game.move_buf); + delete game.move_buf; +} + +function print_turn_log_no_count(text) { + function print_move(last) { + return "\n" + last.join(" \u2192 "); + } + if (game.turn_log.length > 0) { + game.turn_log.sort(); + for (let entry of game.turn_log) + text += print_move(entry); + } else { + text += "\nnothing."; + } + log(text); + delete game.turn_log; +} + +function print_turn_log(text) { + function print_move(last) { + return "\n" + n + " " + last.join(" \u2192 "); + } + game.turn_log.sort(); + let last = game.turn_log[0]; + let n = 0; + for (let entry of game.turn_log) { + if (entry.toString() != last.toString()) { + text += print_move(last); + n = 0; + } + ++n; + last = entry; + } + if (n > 0) + text += print_move(last); + else + text += "\nnothing."; + log(text); + delete game.turn_log; +} + +function is_active_player(current) { + return (current == game.active) || (game.active == BOTH && current != OBSERVER); +} + +function is_inactive_player(current) { + return current == OBSERVER || (game.active != current && game.active != BOTH); +} + +function remove_from_array(array, item) { + let i = array.indexOf(item); + if (i >= 0) + array.splice(i, 1); +} + +function clear_undo() { + game.undo = []; +} + +function push_undo() { + game.undo.push(JSON.stringify(game, (k,v) => { + if (k === 'undo') return undefined; + if (k === 'log') return v.length; + return v; + })); +} + +function pop_undo() { + let undo = game.undo; + let log = game.log; + Object.assign(game, JSON.parse(undo.pop())); + game.undo = undo; + log.length = game.log; + game.log = log; +} + +function gen_action_undo(view) { + if (!view.actions) + view.actions = {} + if (game.undo && game.undo.length > 0) + view.actions.undo = 1; + else + view.actions.undo = 0; +} + +function gen_action(view, action, argument) { + if (!view.actions) + view.actions = {} + if (argument != undefined) { + if (!(action in view.actions)) { + view.actions[action] = [ argument ]; + } else { + if (!view.actions[action].includes(argument)) + view.actions[action].push(argument); + } + } else { + view.actions[action] = true; + } +} + +function roll_d6() { + return Math.floor(Math.random() * 6) + 1; +} + +function shuffle_deck() { + let deck = []; + for (let c = 1; c <= 27; ++c) + deck.push(c); + return deck; +} + +function deal_cards(deck, n) { + let hand = []; + for (let i = 0; i < n; ++i) { + let k = Math.floor(Math.random() * deck.length); + hand.push(deck[k]); + deck.splice(k, 1); + } + return hand; +} + +function block_name(who) { + return BLOCKS[who].name; +} + +function block_type(who) { + return BLOCKS[who].type; +} + +function block_home(who) { + let home = BLOCKS[who].home; + if (home == "Aquitaine") return "England"; + if (home == "Bourgogne") return "France"; + if (home == "Flanders") return "France"; + return home; +} + +function block_pool(who) { + if (BLOCKS[who].owner == FRANK) + return F_POOL; + return S_POOL; +} + +function block_owner(who) { + return BLOCKS[who].owner; +} + +function block_initiative(who) { + return BLOCKS[who].combat[0]; +} + +function block_printed_fire_power(who) { + return BLOCKS[who].combat[1] | 0; +} + +function block_fire_power(who, where) { + let combat = block_printed_fire_power(who); + return combat; +} + +function block_move(who) { + return BLOCKS[who].move; +} + +function block_max_steps(who) { + return BLOCKS[who].steps; +} + +function is_block_on_map(who) { + return game.location[who] && game.location[who] != F_POOL && game.location[who] != S_POOL; +} + +function can_activate(who) { + return block_owner(who) == game.active && is_block_on_map(who) && !game.moved[who]; +} + +function road_id(a, b) { + return (a < b) ? a + "/" + b : b + "/" + a; +} + +function road_was_last_used_by_enemy(from, to) { + return game.last_used[road_id(from, to)] == ENEMY[game.active]; +} + +function road_type(a, b) { + return ROADS[road_id(a,b)]; +} + +function road_limit(a, b) { + return game.road_limit[road_id(a,b)] || 0; +} + +function reset_road_limits() { + game.road_limit = {}; +} + +function count_friendly(where) { + let p = game.active; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + ++count; + return count; +} + +function count_enemy(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + ++count; + return count; +} + +function count_enemy_excluding_reserves(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_friendly_town(where) { return count_friendly(where) > 0 && count_enemy(where) == 0; } +function is_enemy_town(where) { return count_friendly(where) == 0 && count_enemy(where) > 0; } +function is_vacant_town(where) { return count_friendly(where) == 0 && count_enemy(where) == 0; } +function is_contested_town(where) { return count_friendly(where) > 0 && count_enemy(where) > 0; } +function is_friendly_or_vacant_town(where) { return is_friendly_town(where) || is_vacant_town(where); } + +function is_fortified_port(where) { + return TOWNS[where].fortified_port; +} + +function is_port(where) { + return TOWNS[where].port; +} + +function is_friendly_port(where) { + // TODO: Tripoli/Tyre are friendly to besieged defender! + return TOWNS[where].port && is_friendly_town(where); +} + +function have_contested_towns() { + for (let where in TOWNS) + if (is_contested_town(where)) + return true; + return false; +} + +function count_pinning(where) { + return count_enemy_excluding_reserves(where); +} + +function count_pinned(where) { + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == game.active) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_pinned(who, from) { + if (game.active == game.p2) { + if (count_pinned(from) <= count_pinning(from)) + return true; + } + return false; +} + +function can_block_use_road(who, from, to) { + switch (road_type(from, to)) { + case 'major': return road_limit(from, to) < 4; + case 'minor': return road_limit(from, to) < 2; + } + return false; +} + +function can_block_land_move_to(who, from, to) { + if (can_block_use_road(who, from, to)) { + if (count_pinning(from) > 0) + if (road_was_last_used_by_enemy(from, to)) + return false; + return true; + } + return false; +} + +function can_block_land_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (from) { + if (is_pinned(who, from)) + return false; + for (let to of TOWNS[from].exits) + if (can_block_land_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_sea_move_to(who, from, to) { + if (is_port(to)) { + // English Crusaders may attack by sea if they are the Main Attacker + if (who == "Richard" || who == "Robert" || who == "Crossbows") { + if (game.attacker[to] != FRANK) + return false; + if (game.main_road[to] != "England") + return false; + return true; + } + return is_friendly_port(to); + } + return false; +} + +function can_block_sea_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (is_friendly_port(from)) { + for (let to of PORTS) + if (to != from && can_block_sea_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_continue(who, from, to) { + if (is_contested_town(to)) + return false; + if (game.distance >= block_move(who)) + return false; + return true; +} + +function can_block_retreat_to(who, to) { + if (is_friendly_town(to) || is_vacant_town(to)) { + let from = game.location[who]; + if (can_block_use_road(who, from, to)) { + if (road_was_last_used_by_enemy(from, to)) + return false; + return true; + } + } + return false; +} + +function can_block_regroup_to(who, to) { + if (is_friendly_town(to) || is_vacant_town(to)) { + let from = game.location[who]; + if (can_block_use_road(who, from, to)) + return true; + } + return false; +} + +function can_block_regroup(who) { + if (block_owner(who) == game.active) { + let from = game.location[who]; + for (let to of TOWNS[from].exits) + if (can_block_regroup_to(who, to)) + return true; + } + return false; +} + +function can_block_muster_via(who, from, next, muster) { + if (can_block_land_move_to(who, from, next) && is_friendly_or_vacant_town(next)) { + if (next == muster) + return true; + if (road_type(from, next) != 'minor') { + if (TOWNS[next].exits.includes(muster)) + if (can_block_land_move_to(who, next, muster)) + return true; + } + } +} + +function can_block_muster(who, muster) { + let from = game.location[who]; + if (from == muster) + return false; + if (can_activate(who)) { + if (is_pinned(who, from)) + return false; + for (let next of TOWNS[from].exits) + if (can_block_muster_via(who, from, next, muster)) + return true; + } + return false; +} + +function can_muster_to(muster) { + for (let b in BLOCKS) + if (can_block_muster(b, muster)) + return true; + return false; +} + +function is_battle_reserve(who) { + return game.reserves.includes(who); +} + +function is_attacker(who) { + if (game.location[who] == game.where && block_owner(who) == game.attacker[game.where]) + return !game.reserves.includes(who); + return false; +} + +function is_defender(who) { + if (game.location[who] == game.where && block_owner(who) != game.attacker[game.where]) + return !game.reserves.includes(who); + return false; +} + +function disband(who) { + log(block_name(who) + " disbands."); + game.location[who] = block_pool(who); + game.steps[who] = block_max_steps(who); +} + +function eliminate_block(who) { + log(block_name(who) + " is eliminated."); + game.location[who] = null; + game.steps[who] = block_max_steps(who); +} + +function reduce_block(who) { + if (game.steps[who] == 1) { + eliminate_block(who); + } else { + --game.steps[who]; + } +} + +function filter_battle_blocks(ci, is_candidate) { + let output = null; + for (let b in BLOCKS) { + if (is_candidate(b) && !game.moved[b]) { + if (block_initiative(b) == ci) { + if (!output) + output = []; + output.push(b); + } + } + } + return output; +} + +function count_attackers() { + let count = 0; + for (let b in BLOCKS) + if (is_attacker(b)) + ++count; + return count; +} + +function count_defenders() { + let count = 0; + for (let b in BLOCKS) + if (is_defender(b)) + ++count; + return count; +} + +// GAME TURN + +function start_year() { + log(""); + log("Start Year " + game.year + "."); + + let deck = shuffle_deck(); + game.f_hand = deal_cards(deck, 6); + game.s_hand = deal_cards(deck, 6); + + start_game_turn(); +} + +function start_game_turn() { + // Reset movement and attack tracking state + reset_road_limits(); + game.last_used = {}; + game.attacker = {}; + game.reserves = []; + game.moved = {}; + + goto_card_phase(); +} + +function end_game_turn() { + if (game.f_hand.length > 0) + start_game_turn() + else + goto_winter_turn(); +} + +// CARD PHASE + +function goto_card_phase() { + game.f_card = 0; + game.s_card = 0; + game.show_cards = false; + game.state = 'play_card'; + game.active = BOTH; +} + +states.play_card = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for players to play a card."; + if (current == FRANK) { + if (game.f_card) { + view.prompt = "Waiting for Saracen to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.f_hand) + gen_action(view, 'play', c); + } + } + if (current == SARACEN) { + if (game.s_card) { + view.prompt = "Waiting for Frank to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.s_hand) + gen_action(view, 'play', c); + } + } + }, + play: function (card, current) { + if (current == FRANK) { + remove_from_array(game.f_hand, card); + game.f_card = card; + } + if (current == SARACEN) { + remove_from_array(game.s_hand, card); + game.s_card = card; + } + if (game.s_card > 0 && game.f_card > 0) + reveal_cards(); + }, + undo: function (_, current) { + if (current == FRANK) { + game.f_hand.push(game.f_card); + game.f_card = 0; + } + if (current == SARACEN) { + game.s_hand.push(game.s_card); + game.s_card = 0; + } + } +} + +function reveal_cards() { + log("Frank plays " + CARDS[game.f_card].name + "."); + log("Saracen plays " + CARDS[game.s_card].name + "."); + game.show_cards = true; + + let fc = CARDS[game.f_card]; + let sc = CARDS[game.s_card]; + + if (fc.event && sc.event) { + log("Game Turn is cancelled."); + end_game_turn(); + return; + } + + let fp = fc.moves; + let sp = sc.moves; + if (fp == sp) { + if (roll_d6() > 3) + ++fp; + else + ++sp; + } + + if (fp > sp) { + game.p1 = FRANK; + game.p2 = SARACEN; + } else { + game.p1 = SARACEN; + game.p2 = FRANK; + } + + game.active = game.p1; + start_player_turn(); +} + +function start_player_turn() { + log(""); + log("Start " + game.active + " turn."); + reset_road_limits(); + let card = CARDS[game.active == FRANK ? game.f_card : game.s_card]; + if (card.event) + goto_event_card(card.event); + else + goto_move_phase(card.moves); +} + +function end_player_turn() { + game.moves = 0; + game.activated = null; + game.main_road = null; + + if (game.active == game.p2) { + goto_battle_phase(); + } else { + game.active = game.p2; + start_player_turn(); + } +} + +// EVENTS + +function goto_event_card(event) { + end_player_turn(); +} + +// ACTION PHASE + +function move_block(who, from, to) { + game.location[who] = to; + game.road_limit[road_id(from, to)] = road_limit(from, to) + 1; + game.distance ++; + if (is_contested_town(to)) { + game.last_used[road_id(from, to)] = game.active; + if (!game.attacker[to]) { + game.attacker[to] = game.active; + game.main_road[to] = from; + return ATTACK_MARK; + } else { + // Attacker main attack or reinforcements + if (game.attacker[to] == game.active) { + if (game.main_road[to] != from) { + game.reserves.push(who); + return RESERVE_MARK_1; + } + return ATTACK_MARK; + } + + // Defender reinforcements + if (!game.main_road[to]) + game.main_road[to] = from; + + if (game.main_road[to] == from) { + game.reserves.push(who); + return RESERVE_MARK_1; + } else { + game.reserves.push(who); + return RESERVE_MARK_2; + } + } + } + return false; +} + +function goto_move_phase(moves) { + game.state = 'group_move'; + game.moves = moves; + game.activated = []; + game.main_road = {}; + game.turn_log = []; +} + +function end_move_phase() { + game.moves = 0; + clear_undo(); + print_turn_log(game.active + " moves:"); + end_player_turn(); +} + +states.group_move = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Move Phase: Waiting for " + game.active + "."; + view.prompt = "Group Move: Choose a block to group move. " + game.moves + "AP left."; + gen_action_undo(view); + gen_action(view, 'end_move_phase'); + if (game.moves > 0) { + gen_action(view, 'sea_move'); + gen_action(view, 'muster'); + for (let b in BLOCKS) + if (can_block_land_move(b)) + gen_action(view, 'block', b); + } else { + for (let b in BLOCKS) { + let from = game.location[b]; + if (game.activated.includes(from)) + if (can_block_land_move(b)) + gen_action(view, 'block', b); + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.origin = game.location[who]; + game.distance = 0; + game.state = 'group_move_to'; + }, + sea_move: function () { + game.state = 'sea_move'; + }, + muster: function () { + game.state = 'muster'; + }, + end_move_phase: end_move_phase, + undo: pop_undo +} + +states.group_move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Group Move: Move " + block_name(game.who) + "."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + if (game.distance > 0) + gen_action(view, 'town', from); + for (let to of TOWNS[from].exits) { + if (can_block_land_move_to(game.who, from, to)) + gen_action(view, 'town', to); + } + }, + town: function (to) { + let from = game.location[game.who]; + if (to == from) { + end_move(); + return; + } + if (game.distance == 0) + log_move_start(from); + let mark = move_block(game.who, from, to); + if (mark) + log_move_continue(to + mark); + else + log_move_continue(to); + if (!can_block_continue(game.who, from, to)) + end_move(); + }, + block: function () { + if (game.distance == 0) + pop_undo(); + else + end_move(); + }, + undo: pop_undo +} + +states.sea_move = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Move Phase: Waiting for " + game.active + "."; + view.prompt = "Sea Move: Choose a block to sea move. " + game.moves + "AP left."; + gen_action_undo(view); + gen_action(view, 'end_move_phase'); + gen_action(view, 'group_move'); + if (game.moves > 0) { + gen_action(view, 'muster'); + for (let b in BLOCKS) { + let from = game.location[b]; + if (can_block_sea_move(b)) { + gen_action(view, 'block', b); + } + } + } + }, + group_move: function () { + game.state = 'group_move'; + }, + muster: function () { + game.state = 'muster'; + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'sea_move_to'; + }, + end_move_phase: end_move_phase, + undo: pop_undo +} + +states.sea_move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Sea Move " + block_name(game.who) + "."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + for (let to of PORTS) + if (to != from && can_block_sea_move_to(game.who, from, to)) + gen_action(view, 'town', to); + }, + town: function (to) { + --game.moves; + let from = game.location[game.who]; + game.location[game.who] = to; + game.moved[game.who] = true; + log_move_start(from); + log_move_continue("Sea"); + + // English Crusaders attack! + if (is_contested_town(to)) { + game.attacker[to] = FRANK; + game.main_road[to] = "England"; + log_move_continue(to + ATTACK_MARK); + } else { + log_move_continue(to); + } + + game.state = 'sea_move'; + game.who = null; + }, + block: pop_undo, + undo: pop_undo +} + +states.muster = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Muster: Choose one friendly or vacant muster area."; + gen_action_undo(view); + gen_action(view, 'group_move'); + gen_action(view, 'sea_move'); + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let where in TOWNS) { + if (is_friendly_or_vacant_town(where)) + if (can_muster_to(where)) + gen_action(view, 'town', where); + } + }, + area: function (where) { + push_undo(); + game.where = where; + game.state = 'muster_who'; + }, + end_action_phase: function () { + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +states.muster_who = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Muster: Move blocks to the designated muster area."; + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let b in BLOCKS) + if (can_block_muster(b, game.where)) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'muster_move_1'; + }, + end_action_phase: function () { + game.where = null; + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +function end_move() { + if (game.distance > 0) { + let to = game.location[game.who]; + if (!game.activated.includes(game.origin)) { + logp("activates " + game.origin + "."); + game.activated.push(game.origin); + game.moves --; + } + game.moved[game.who] = true; + } + log_move_end(); + game.who = null; + game.distance = 0; + game.origin = null; + game.state = 'group_move'; +} + +// BATTLE PHASE + +function goto_battle_phase() { + if (have_contested_towns()) { + game.active = game.p1; + game.state = 'battle_phase'; + } else { + goto_draw_phase(); + } +} + +states.battle_phase = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a battle."; + view.prompt = "Choose the next battle to fight!"; + for (let where in TOWNS) + if (is_contested_town(where)) + gen_action(view, 'town', where); + }, + town: function (where) { + start_battle(where); + }, +} + +function start_battle(where) { + game.flash = ""; + log(""); + log("Battle in " + where + "."); + game.where = where; + game.battle_round = 0; + game.state = 'battle_round'; + start_battle_round(); +} + +function resume_battle() { + if (game.victory) + return goto_game_over(); + game.who = null; + game.state = 'battle_round'; + pump_battle_round(); +} + +function end_battle() { + game.flash = ""; + game.battle_round = 0; + reset_road_limits(); + game.moved = {}; + + game.active = game.attacker[game.where]; + let victor = game.active; + if (is_contested_town(game.where)) + victor = ENEMY[game.active]; + else if (is_enemy_town(game.where)) + victor = ENEMY[game.active]; + log(victor + " wins the battle in " + game.where + "!"); + + goto_retreat(); +} + +function bring_on_reserves(round) { + // TODO: defender reserves in round 3... + for (let b in BLOCKS) { + if (game.location[b] == game.where) { + remove_from_array(game.reserves, b); + } + } +} + +function start_battle_round() { + if (++game.battle_round <= 3) { + log("~ Battle Round " + game.battle_round + " ~"); + + reset_road_limits(); + game.moved = {}; + + if (game.battle_round == 2) { + if (count_defenders() == 0) { + log("Defending main force was eliminated."); + log("The attacker is now the defender."); + game.attacker[game.where] = ENEMY[game.attacker[game.where]]; + } else if (count_attackers() == 0) { + log("Attacking main force was eliminated."); + } + bring_on_reserves(2); + } + + if (game.battle_round == 3) { + bring_on_reserves(3); + } + + pump_battle_round(); + } else { + end_battle(); + } +} + +function pump_battle_round() { + if (is_friendly_town(game.where) || is_enemy_town(game.where)) { + end_battle(); + } else if (count_attackers() == 0 || count_defenders() == 0) { + start_battle_round(); + } else { + function battle_step(active, initiative, candidate) { + game.battle_list = filter_battle_blocks(initiative, candidate); + if (game.battle_list) { + game.active = active; + return true; + } + return false; + } + + let attacker = game.attacker[game.where]; + let defender = ENEMY[attacker]; + + if (battle_step(defender, 'A', is_defender)) return; + if (battle_step(attacker, 'A', is_attacker)) return; + if (battle_step(defender, 'B', is_defender)) return; + if (battle_step(attacker, 'B', is_attacker)) return; + if (battle_step(defender, 'C', is_defender)) return; + if (battle_step(attacker, 'C', is_attacker)) return; + + start_battle_round(); + } +} + +function retreat_with_block(b) { + game.who = b; + game.state = 'retreat_in_battle'; +} + +function roll_attack(verb, b) { + game.hits = 0; + let fire = block_fire_power(b, game.where); + let printed_fire = block_printed_fire_power(b); + let rolls = []; + let results = []; + let steps = game.steps[b]; + for (let i = 0; i < steps; ++i) { + let die = roll_d6(); + rolls.push(die); + if (die <= fire) { + results.push(HIT_TEXT); + ++game.hits; + } else { + results.push(MISS_TEXT); + } + } + game.flash += block_name(b) + " " + BLOCKS[b].combat; + if (fire > printed_fire) + game.flash += "+" + (fire - printed_fire); + game.flash += " " + verb + "\n" + rolls.join(" ") + " = " + results.join(" "); +} + +function fire_with_block(b) { + game.moved[b] = true; + game.flash = ""; + roll_attack("fires", b); + log(game.flash); + if (game.hits > 0) { + game.active = ENEMY[game.active]; + goto_battle_hits(); + } else { + resume_battle(); + } +} + +states.battle_round = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a combat action."; + view.prompt = "Battle: Charge, Fire, Harry, or Retreat with an army."; + for (let b of game.battle_list) { + gen_action(view, 'block', b); // take default action + gen_action(view, 'battle_fire', b); + gen_action(view, 'battle_retreat', b); + // Turcopoles and Nomads can Harry (fire and retreat) + if (block_type(b) == 'turcopoles' || block_type(b) == 'nomads') + gen_action(view, 'battle_harry', b); + // All Frank B blocks are knights who can Charge + if (block_owner(b) == FRANK && block_initiative(b) == 'B') + gen_action(view, 'battle_charge', b); + } + }, + battle_charge: function (who) { + charge_with_block(who); + }, + battle_fire: function (who) { + fire_with_block(who); + }, + battle_harry: function (who) { + harry_with_block(who); + }, + battle_retreat: function (who) { + retreat_with_block(who); + }, + block: function (who) { + fire_with_block(who); + }, +} + +function goto_battle_hits() { + game.battle_list = list_victims(game.active); + if (game.battle_list.length == 0) + resume_battle(); + else + game.state = 'battle_hits'; +} + +function apply_hit(who) { + game.flash = block_name(who) + " takes a hit."; + log(game.flash); + reduce_block(who, 'combat'); + game.hits--; + if (game.hits == 1) + game.flash += " 1 hit left."; + else if (game.hits > 1) + game.flash += " " + game.hits + " hits left."; + if (game.hits == 0) + resume_battle(); + else + goto_battle_hits(); +} + +function list_victims(p) { + let is_candidate = (p == game.attacker[game.where]) ? is_attacker : is_defender; + let max = 0; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] > max) + max = game.steps[b]; + let list = []; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] == max) + list.push(b); + return list; +} + +states.battle_hits = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to assign hits."; + view.prompt = "Assign " + game.hits + (game.hits != 1 ? " hits" : " hit") + " to your armies."; + for (let b of game.battle_list) { + gen_action(view, 'battle_hit', b); + gen_action(view, 'block', b); + } + }, + battle_hit: function (who) { + apply_hit(who); + }, + block: function (who) { + apply_hit(who); + }, +} + +function goto_retreat() { + game.active = game.attacker[game.where]; + if (is_contested_town(game.where)) { + game.state = 'retreat'; + game.turn_log = []; + clear_undo(); + } else { + clear_undo(); + goto_regroup(); + } +} + +states.retreat = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + view.prompt = "Retreat: Choose an army to move."; + gen_action_undo(view); + let can_retreat = false; + for (let b in BLOCKS) { + if (game.location[b] == game.where && can_block_retreat(b)) { + gen_action(view, 'block', b); + can_retreat = true; + } + } + if (!is_contested_town(game.where) || !can_retreat) + gen_action(view, 'end_retreat'); + }, + end_retreat: function () { + for (let b in BLOCKS) + if (game.location[b] == game.where && block_owner(b) == game.active) + eliminate_block(b); + print_turn_log(game.active + " retreats:"); + clear_undo(); + goto_regroup(); + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'retreat_to'; + }, + undo: pop_undo +} + +states.retreat_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + view.prompt = "Retreat: Move the army to a friendly or neutral area."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let can_retreat = false; + for (let to of AREAS[game.where].exits) { + if (can_block_retreat_to(game.who, to)) { + gen_action(view, 'area', to); + can_retreat = true; + } + } + if (!can_retreat) + gen_action(view, 'eliminate'); + }, + area: function (to) { + let from = game.where; + game.turn_log.push([from, to]); + move_block(game.who, game.where, to); + game.who = null; + game.state = 'retreat'; + }, + eliminate: function () { + eliminate_block(game.who); + game.who = null; + game.state = 'retreat'; + }, + block: pop_undo, + undo: pop_undo +} + +states.retreat_in_battle = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + gen_action(view, 'undo'); + gen_action(view, 'block', game.who); + view.prompt = "Retreat: Move the army to a friendly or vacant area."; + for (let to of TOWNS[game.where].exits) + if (can_block_retreat_to(game.who, to)) + gen_action(view, 'town', to); + }, + town: function (to) { + logp("retreats to " + to + "."); + game.location[game.who] = to; + resume_battle(); + }, + eliminate: function () { + eliminate_block(game.who); + resume_battle(); + }, + block: function (to) { + resume_battle(); + }, + undo: function () { + resume_battle(); + } +} + +function goto_regroup() { + game.active = game.attacker[game.where]; + if (is_enemy_town(game.where)) + game.active = ENEMY[game.active]; + log(game.active + " wins the battle in " + game.where + "."); + game.state = 'regroup'; + game.turn_log = []; +} + +states.regroup = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Choose an army to move."; + gen_action_undo(view); + gen_action(view, 'end_regroup'); + for (let b in BLOCKS) { + if (game.location[b] == game.where) { + if (can_block_regroup(b)) + gen_action(view, 'block', b); + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'regroup_to'; + }, + end_regroup: function () { + print_turn_log(game.active + " regroups:"); + game.where = null; + clear_undo(); + goto_battle_phase(); + }, + undo: pop_undo +} + +states.regroup_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Move the army to a friendly or vacant area."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + for (let to of TOWNS[game.where].exits) + if (can_block_regroup_to(game.who, to)) + gen_action(view, 'town', to); + }, + town: function (to) { + game.turn_log.push([from, to]); + move_block(game.who, game.where, to); + game.who = null; + game.state = 'regroup'; + }, + block: pop_undo, + undo: pop_undo +} + +// DRAW PHASE + +function goto_draw_phase() { + end_game_turn(); +} + +// GAME OVER + +function goto_game_over() { + game.active = "None"; + game.state = 'game_over'; +} + +states.game_over = { + prompt: function (view, current) { + view.prompt = game.victory; + } +} + +// SETUP + +function deploy(who, where) { + game.location[who] = where; + game.steps[who] = block_max_steps(who); +} + +function reset_blocks() { + for (let b in BLOCKS) { + game.location[b] = null; + game.steps[b] = block_max_steps(b); + } +} + +function setup_game() { + reset_blocks(); + game.year = 1187; + for (let b in BLOCKS) { + if (block_owner(b) == FRANK) { + switch (block_type(b)) { + case 'pilgrims': + case 'crusaders': + deploy(b, block_pool(b)); + break; + default: + deploy(b, block_home(b)); + break; + } + } + if (block_owner(b) == SARACEN) { + if (block_type(b) == 'emirs') + deploy(b, block_home(b)); + if (block_type(b) == 'nomads') + deploy(b, block_pool(b)); + } + if (block_owner(b) == ASSASSINS) { + deploy(b, block_home(b)); + } + } +} + +// VIEW + +function make_battle_view() { + let battle = { + FA: [], FB: [], FC: [], FR: [], + SA: [], SB: [], SC: [], SR: [], + flash: game.flash + }; + + battle.title = game.attacker[game.where] + " attacks " + game.where; + battle.title += " \u2014 round " + game.battle_round + " of 3"; + + function fill_cell(cell, owner, fn) { + for (let b in BLOCKS) + if (game.location[b] == game.where & block_owner(b) == owner && fn(b)) + cell.push([b, game.steps[b], game.moved[b]?1:0]) + } + + fill_cell(battle.FR, FRANK, b => is_battle_reserve(b)); + fill_cell(battle.FA, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.FB, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.FC, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + + fill_cell(battle.SR, SARACEN, b => is_battle_reserve(b)); + fill_cell(battle.SA, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.SB, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.SC, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + + return battle; +} + +exports.ready = function (scenario, players) { + return players.length === 2; +} + +exports.setup = function (scenario, players) { + game = { + attacker: {}, + road_limit: {}, + last_used: {}, + location: {}, + log: [], + main_road: {}, + moved: {}, + moves: 0, + prompt: null, + reserves: [], + show_cards: false, + steps: {}, + who: null, + where: null, + undo: [], + } + setup_game(); + start_year(); + return game; +} + +exports.action = function (state, current, action, arg) { + game = state; + // TODO: check action and argument against action list + if (is_active_player(current)) { + let S = states[game.state]; + if (action in S) { + S[action](arg, current); + } else { + throw new Error("Invalid action: " + action); + } + } + return state; +} + +exports.resign = function (state, current) { + game = state; + if (game.state != 'game_over') { + log(""); + log(current + " resigned."); + game.active = "None"; + game.state = 'game_over'; + game.victory = current + " resigned."; + game.result = ENEMY[current]; + } +} + +exports.view = function(state, current) { + game = state; + + let view = { + log: game.log, + year: game.year, + active: game.active, + f_card: (game.show_cards || current == FRANK) ? game.f_card : 0, + s_card: (game.show_cards || current == SARACEN) ? game.s_card : 0, + hand: (current == FRANK) ? game.f_hand : (current == SARACEN) ? game.s_hand : [], + who: (game.active == current) ? game.who : null, + where: game.where, + known: {}, + secret: { Frank: {}, Saracen: {}, Assassins: {} }, + battle: null, + prompt: null, + actions: null, + }; + + states[game.state].prompt(view, current); + + if (states[game.state].show_battle) + view.battle = make_battle_view(); + + for (let b in BLOCKS) { + let a = game.location[b]; + if (!a) + continue; + if (a == F_POOL) // && current != FRANK) + continue; + if (a == S_POOL) // && current != SARACEN) + continue; + if (a == F_POOL || a == S_POOL) + a = "Pool"; + + let is_known = false; + if (current == block_owner(b)) + is_known = true; + if (b == ASSASSINS) + is_known = true; + + if (is_known) { + view.known[b] = [a, game.steps[b], game.moved[b] ? 1 : 0]; + } else { + let list = view.secret[BLOCKS[b].owner]; + if (!(a in list)) + list[a] = [0, 0]; + list[a][0]++; + if (game.moved[b]) + list[a][1]++; + } + } + + return view; +} diff --git a/thumbnail.jpg b/thumbnail.jpg new file mode 100644 index 0000000..1f6ebcb Binary files /dev/null and b/thumbnail.jpg differ diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..c0a164d --- /dev/null +++ b/ui.js @@ -0,0 +1,718 @@ +"use strict"; + +const FRANK = "Frank"; +const SARACEN = "Saracen"; +const ASSASSINS = "Assassins"; +const ENEMY = { Saracen: "Frank", Frank: "Saracen" } +const POOL = "Pool"; + +function toggle_blocks() { + document.getElementById("map").classList.toggle("hide_blocks"); +} + +let map_orientation = window.localStorage['crusader-rex/map-orientation'] || 'tall'; + +function tall_map() { + map_orientation = 'tall'; + document.querySelector(".map").classList.remove("wide"); + document.querySelector(".map").classList.add("tall"); + window.localStorage['crusader-rex/map-orientation'] = map_orientation; + update_map_layout(); + update_map(); + zoom_map(); +} + +function wide_map() { + map_orientation = 'wide'; + document.querySelector(".map").classList.add("wide"); + document.querySelector(".map").classList.remove("tall"); + window.localStorage['crusader-rex/map-orientation'] = map_orientation; + update_map_layout(); + update_map(); + zoom_map(); +} + +let game = null; + +let ui = { + cards: {}, + towns: {}, + known: {}, + secret: { Frank: {}, Saracen: {}, Assassins: {} }, + battle_menu: {}, + battle_block: {}, + present: new Set(), +} + +function on_focus_town(evt) { + let where = evt.target.town; + let text = where; + document.getElementById("status").textContent = text; +} + +function on_blur_town(evt) { + document.getElementById("status").textContent = ""; +} + +function on_click_town(evt) { + let where = evt.target.town; + if (game.actions && game.actions.town && game.actions.town.includes(where)) + socket.emit('action', 'town', where); +} + +const STEP_TEXT = [ 0, "I", "II", "III", "IIII" ]; +const HEIR_TEXT = [ 0, '\u00b9', '\u00b2', '\u00b3', '\u2074', '\u2075' ]; + +function block_name(who) { return BLOCKS[who].name; } +function block_home(who) { return BLOCKS[who].home; } +function block_owner(who) { return BLOCKS[who].owner; } + +function on_focus_secret_block(evt) { + let owner = evt.target.owner; + let text = owner; + document.getElementById("status").textContent = text; +} + +function on_blur_secret_block(evt) { + document.getElementById("status").textContent = ""; +} + +function on_click_secret_block(evt) { +} + +function on_focus_map_block(evt) { + let b = evt.target.block; + let s = game.known[b][1]; + let text = block_name(b) + " (" + block_home(b) + ") "; + if (BLOCKS[b].move) + text += BLOCKS[b].move + "-"; + text += STEP_TEXT[s] + "-" + BLOCKS[b].combat; + document.getElementById("status").textContent = text; +} + +function on_blur_map_block(evt) { + document.getElementById("status").textContent = ""; +} + +function on_click_map_block(evt) { + let b = evt.target.block; + if (game.actions && game.actions.block && game.actions.block.includes(b)) + socket.emit('action', 'block', b); +} + +function is_battle_reserve(who, list) { + for (let [b, s, m] of list) + if (who == b) + return true; + return false; +} + +function on_focus_battle_block(evt) { + let b = evt.target.block; + let msg = block_name(b); + if (is_battle_reserve(b, game.battle.FR)) + msg = "Frank Reserve"; + if (is_battle_reserve(b, game.battle.SR)) + msg = "Saracen Reserve"; + + if (game.actions && game.actions.battle_fire && game.actions.battle_fire.includes(b)) + msg = "Fire with " + msg; + else if (game.actions && game.actions.battle_retreat && game.actions.battle_retreat.includes(b)) + msg = "Retreat with " + msg; + else if (game.actions && game.actions.battle_hit && game.actions.battle_hit.includes(b)) + msg = "Take hit on " + msg; + + document.getElementById("status").textContent = msg; +} + +function on_blur_battle_block(evt) { + document.getElementById("status").textContent = ""; +} + +function on_click_battle_block(evt) { + let b = evt.target.block; + if (game.actions && game.actions.block && game.actions.block.includes(b)) + socket.emit('action', 'block', b); +} + +function on_focus_battle_fire(evt) { + document.getElementById("status").textContent = + "Fire with " + block_name(evt.target.block); +} + +function on_focus_battle_retreat(evt) { + document.getElementById("status").textContent = + "Retreat with " + block_name(evt.target.block); +} + +function on_focus_battle_harry(evt) { + document.getElementById("status").textContent = + "Harry with " + block_name(evt.target.block); +} + +function on_focus_battle_charge(evt) { + document.getElementById("status").textContent = + "Charge with " + block_name(evt.target.block); +} + +function on_focus_battle_hit(evt) { + document.getElementById("status").textContent = + "Take hit on " + block_name(evt.target.block); +} + +function on_blur_battle_button(evt) { + document.getElementById("status").textContent = ""; +} + +function on_click_battle_hit(evt) { socket.emit('action', 'battle_hit', evt.target.block); } +function on_click_battle_fire(evt) { socket.emit('action', 'battle_fire', evt.target.block); } +function on_click_battle_retreat(evt) { socket.emit('action', 'battle_retreat', evt.target.block); } +function on_click_battle_charge(evt) { socket.emit('action', 'battle_charge', evt.target.block); } +function on_click_battle_harry(evt) { socket.emit('action', 'battle_harry', evt.target.block); } + +function on_click_card(evt) { + let c = evt.target.id.split("+")[1] | 0; + if (game.actions && game.actions.play && game.actions.play.includes(c)) + socket.emit('action', 'play', c); +} + +function on_button_undo(evt) { send_action('undo'); } +function on_button_pass(evt) { send_action('pass'); } +function on_button_sea_move(evt) { send_action('sea_move'); } +function on_button_group_move(evt) { send_action('group_move'); } +function on_button_muster(evt) { send_action('muster'); } +function on_button_end_muster(evt) { send_action('end_muster'); } +function on_button_end_move_phase(evt) { send_action('end_move_phase'); } +function on_button_end_regroup(evt) { send_action('end_regroup'); } +function on_button_end_retreat(evt) { send_action('end_retreat'); } +function on_button_eliminate(evt) { send_action('eliminate'); } + +function build_battle_button(menu, b, c, click, enter, img_src) { + let img = new Image(); + img.draggable = false; + img.classList.add("action"); + img.classList.add(c); + img.setAttribute("src", img_src); + img.addEventListener("click", click); + img.addEventListener("mouseenter", enter); + img.addEventListener("mouseleave", on_blur_battle_button); + img.block = b; + menu.appendChild(img); +} + +function build_battle_block(b, block) { + let element = document.createElement("div"); + element.classList.add("block"); + element.classList.add("known"); + element.classList.add(BLOCKS[b].owner); + element.classList.add("block_" + block.image); + element.addEventListener("mouseenter", on_focus_battle_block); + element.addEventListener("mouseleave", on_blur_battle_block); + element.addEventListener("click", on_click_battle_block); + element.block = b; + ui.battle_block[b] = element; + + let menu_list = document.createElement("div"); + menu_list.classList.add("battle_menu_list"); + + build_battle_button(menu_list, b, "hit", + on_click_battle_hit, on_focus_battle_hit, + "/images/cross-mark.svg"); + build_battle_button(menu_list, b, "charge", + on_click_battle_charge, on_focus_battle_charge, + "/images/mounted-knight.svg"); + build_battle_button(menu_list, b, "fire", + on_click_battle_fire, on_focus_battle_fire, + "/images/pointy-sword.svg"); + build_battle_button(menu_list, b, "harry", + on_click_battle_harry, on_focus_battle_harry, + "/images/arrow-flights.svg"); + build_battle_button(menu_list, b, "retreat", + on_click_battle_retreat, on_focus_battle_retreat, + "/images/flying-flag.svg"); + + let menu = document.createElement("div"); + menu.classList.add("battle_menu"); + menu.appendChild(element); + menu.appendChild(menu_list); + ui.battle_menu[b] = menu; +} + +function build_known_block(b, block) { + let element = document.createElement("div"); + element.classList.add("block"); + element.classList.add("known"); + element.classList.add(BLOCKS[b].owner); + element.classList.add("block_" + block.image); + element.addEventListener("mouseenter", on_focus_map_block); + element.addEventListener("mouseleave", on_blur_map_block); + element.addEventListener("click", on_click_map_block); + element.block = b; + return element; +} + +function build_secret_block(b, block) { + let element = document.createElement("div"); + element.classList.add("block"); + element.classList.add("secret"); + element.classList.add(BLOCKS[b].owner); + element.addEventListener("mouseenter", on_focus_secret_block); + element.addEventListener("mouseleave", on_blur_secret_block); + element.addEventListener("click", on_click_secret_block); + element.owner = BLOCKS[b].owner; + return element; +} + +/* +let MAP_OFFSET_X = 30; +let MAP_OFFSET_Y = 30; +let MAP_HEIGHT = 1215; +*/ +let MAP_OFFSET_X = 0; +let MAP_OFFSET_Y = 0; +let MAP_HEIGHT = 1275; + +function town_x(t) { + if (map_orientation == 'tall') + return TOWNS[t].x - MAP_OFFSET_X; + else + return TOWNS[t].y - MAP_OFFSET_Y; +} + +function town_y(t) { + if (map_orientation == 'tall') + return TOWNS[t].y - MAP_OFFSET_Y; + else + return MAP_HEIGHT - TOWNS[t].x + MAP_OFFSET_X; +} + +function flip_x(x, y) { + if (map_orientation == 'tall') + return x - MAP_OFFSET_X; + else + return y - MAP_OFFSET_Y; +} + +function flip_y(x, y) { + if (map_orientation == 'tall') + return y - MAP_OFFSET_Y; + else + return MAP_HEIGHT - x + MAP_OFFSET_X; +} + +function build_town(t, town) { + let element = document.createElement("div"); + element.town = t; + element.classList.add("town"); + element.addEventListener("mouseenter", on_focus_town); + element.addEventListener("mouseleave", on_blur_town); + element.addEventListener("click", on_click_town); + ui.towns_element.appendChild(element); + return element; +} + +function update_map_layout() { + for (let t in TOWNS) { + let element = ui.towns[t]; + element.style.left = (town_x(t) - 35) + "px"; + element.style.top = (town_y(t) - 35) + "px"; + } +} + +function build_map() { + let element; + + ui.blocks_element = document.getElementById("blocks"); + ui.offmap_element = document.getElementById("offmap"); + ui.towns_element = document.getElementById("towns"); + + for (let c = 1; c <= 27; ++c) { + ui.cards[c] = document.getElementById("card+"+c); + ui.cards[c].addEventListener("click", on_click_card); + } + + for (let name in TOWNS) { + let town = TOWNS[name]; + ui.towns[name] = build_town(name, town); + ui.secret.Frank[name] = []; + ui.secret.Saracen[name] = []; + ui.secret.Assassins[name] = []; + } + ui.secret.Frank.offmap = []; + ui.secret.Saracen.offmap = []; + ui.secret.Assassins.offmap = []; + + for (let b in BLOCKS) { + let block = BLOCKS[b]; + build_battle_block(b, block); + ui.known[b] = build_known_block(b, block); + ui.secret[BLOCKS[b].owner].offmap.push(build_secret_block(b, block)); + } + + update_map_layout(); +} + +function update_steps(b, steps, element) { + element.classList.remove("r1"); + element.classList.remove("r2"); + element.classList.remove("r3"); + element.classList.add("r"+(BLOCKS[b].steps - steps)); +} + +function layout_blocks(town, secret, known) { + let wrap = TOWNS[town].wrap; + let s = secret.length; + let k = known.length; + let n = s + k; + let row, rows = []; + let i = 0; + + function new_line() { + rows.push(row = []); + i = 0; + } + + new_line(); + + while (secret.length > 0) { + if (i == wrap) + new_line(); + row.push(secret.shift()); + ++i; + } + + // Break early if secret and known fit in exactly two rows, and more than three blocks total + if (s > 0 && s <= wrap && k > 0 && k <= wrap && n > 3) + new_line(); + + while (known.length > 0) { + if (i == wrap) + new_line(); + row.push(known.shift()); + ++i; + } + + if (TOWNS[town].layout_minor > 0.5) + rows.reverse(); + + for (let j = 0; j < rows.length; ++j) + for (i = 0; i < rows[j].length; ++i) + position_block(town, j, rows.length, i, rows[j].length, rows[j][i]); +} + +function position_block(town, row, n_rows, col, n_cols, element) { + let space = TOWNS[town]; + let block_size = 60+6; + let padding = 4; + let offset = block_size + padding; + let row_size = (n_rows-1) * offset; + let col_size = (n_cols-1) * offset; + let x = space.x; + let y = space.y; + + if (space.layout_axis == 'X') { + x -= col_size * space.layout_major; + y -= row_size * space.layout_minor; + x += col * offset; + y += row * offset; + } else { + y -= col_size * space.layout_major; + x -= row_size * space.layout_minor; + y += col * offset; + x += row * offset; + } + + element.style.left = ((flip_x(x,y) - block_size/2)|0)+"px"; + element.style.top = ((flip_y(x,y) - block_size/2)|0)+"px"; +} + +function show_block(element) { + if (element.parentElement != ui.blocks_element) + ui.blocks_element.appendChild(element); +} + +function hide_block(element) { + if (element.parentElement != ui.offmap_element) + ui.offmap_element.appendChild(element); +} + +function show_block(element) { + if (element.parentElement != ui.blocks_element) + ui.blocks_element.appendChild(element); +} + +function hide_block(element) { + if (element.parentElement != ui.offmap_element) + ui.offmap_element.appendChild(element); +} + +function update_map() { + let overflow = { Frank: [], Saracen: [], Assassins: [] }; + let layout = {}; + + document.getElementById("turn").textContent = "Year " + game.year + " (" + (game.year-1186) + "/6)" ; + + for (let town in TOWNS) + layout[town] = { secret: [], known: [] }; + + // Move secret blocks to overflow queue if there are too many in a town + for (let town in TOWNS) { + for (let color of [FRANK, SARACEN, ASSASSINS]) { + if (game.secret[color]) { + let max = game.secret[color][town] ? game.secret[color][town][0] : 0; + while (ui.secret[color][town].length > max) { + overflow[color].push(ui.secret[color][town].pop()); + } + } + } + } + + // Add secret blocks if there are too few in a location + for (let town in TOWNS) { + for (let color of [FRANK, SARACEN, ASSASSINS]) { + if (game.secret[color]) { + let max = game.secret[color][town] ? game.secret[color][town][0] : 0; + while (ui.secret[color][town].length < max) { + if (overflow[color].length > 0) { + ui.secret[color][town].push(overflow[color].pop()); + } else { + let element = ui.secret[color].offmap.pop(); + show_block(element); + ui.secret[color][town].push(element); + } + } + } + } + } + + // Remove any blocks left in the overflow queue + for (let color of [FRANK, SARACEN, ASSASSINS]) { + while (overflow[color].length > 0) { + let element = overflow[color].pop(); + hide_block(element); + ui.secret[color].offmap.push(element); + } + } + + // Hide formerly known blocks + for (let b in BLOCKS) { + if (!(b in game.known)) { + hide_block(ui.known[b]); + } + } + + // Add secret blocks to layout + for (let town in TOWNS) { + for (let color of [FRANK, SARACEN, ASSASSINS]) { + let i = 0, n = 0, m = 0; + if (game.secret[color] && game.secret[color][town]) { + n = game.secret[color][town][0]; + m = game.secret[color][town][1]; + } + for (let element of ui.secret[color][town]) { + if (i++ < n - m) + element.classList.remove("moved"); + else + element.classList.add("moved"); + layout[town].secret.push(element); + } + } + } + + // Add known blocks to layout + for (let b in game.known) { + let town = game.known[b][0]; + if (town) { + let steps = game.known[b][1]; + let moved = game.known[b][2]; + let element = ui.known[b]; + + show_block(element); + layout[town].known.push(element); + update_steps(b, steps, element); + + if (moved) + element.classList.add("moved"); + else + element.classList.remove("moved"); + } + } + + // Layout blocks on map + for (let town in TOWNS) + layout_blocks(town, layout[town].secret, layout[town].known); + + for (let where in TOWNS) { + if (ui.towns[where]) { + ui.towns[where].classList.remove('highlight'); + ui.towns[where].classList.remove('where'); + } + } + if (game.actions && game.actions.town) + for (let where of game.actions.town) + ui.towns[where].classList.add('highlight'); + if (game.where) + ui.towns[game.where].classList.add('where'); + + for (let b in BLOCKS) { + ui.known[b].classList.remove('highlight'); + ui.known[b].classList.remove('selected'); + } + if (!game.battle) { + if (game.actions && game.actions.block) + for (let b of game.actions.block) + ui.known[b].classList.add('highlight'); + if (game.who) + ui.known[game.who].classList.add('selected'); + } +} + +function update_cards() { + let cards = game.hand; + for (let c = 1; c <= 27; ++c) { + ui.cards[c].classList.remove('enabled'); + if (cards && cards.includes(c)) + ui.cards[c].classList.add('show'); + else + ui.cards[c].classList.remove('show'); + } + + if (game.actions && game.actions.play) { + for (let c of game.actions.play) + ui.cards[c].classList.add('enabled'); + } + + if (!game.f_card) + document.querySelector("#frank_card").className = "small_card card_back"; + else + document.querySelector("#frank_card").className = "small_card " + CARDS[game.f_card].image; + if (!game.s_card) + document.querySelector("#saracen_card").className = "small_card card_back"; + else + document.querySelector("#saracen_card").className = "small_card " + CARDS[game.s_card].image; +} + +function update_battle() { + function fill_cell(name, list, reserve) { + let cell = window[name]; + + ui.present.clear(); + + for (let [block, steps, moved] of list) { + ui.present.add(block); + + if (block == game.who) + ui.battle_block[block].classList.add("selected"); + else + ui.battle_block[block].classList.remove("selected"); + + ui.battle_block[block].classList.remove("highlight"); + ui.battle_menu[block].classList.remove('hit'); + ui.battle_menu[block].classList.remove('charge'); + ui.battle_menu[block].classList.remove('fire'); + ui.battle_menu[block].classList.remove('harry'); + ui.battle_menu[block].classList.remove('retreat'); + + if (game.actions && game.actions.block && game.actions.block.includes(block)) + ui.battle_block[block].classList.add("highlight"); + if (game.actions && game.actions.battle_fire && game.actions.battle_fire.includes(block)) + ui.battle_menu[block].classList.add('fire'); + if (game.actions && game.actions.battle_retreat && game.actions.battle_retreat.includes(block)) + ui.battle_menu[block].classList.add('retreat'); + if (game.actions && game.actions.battle_harry && game.actions.battle_harry.includes(block)) + ui.battle_menu[block].classList.add('harry'); + if (game.actions && game.actions.battle_charge && game.actions.battle_charge.includes(block)) + ui.battle_menu[block].classList.add('charge'); + if (game.actions && game.actions.battle_hit && game.actions.battle_hit.includes(block)) + ui.battle_menu[block].classList.add('hit'); + if (game.actions && game.actions.battle_charge && game.actions.battle_charge.includes(block)) + ui.battle_menu[block].classList.add('charge'); + if (game.actions && game.actions.battle_treachery && game.actions.battle_treachery.includes(block)) + ui.battle_menu[block].classList.add('treachery'); + + update_steps(block, steps, ui.battle_block[block], false); + if (reserve) + ui.battle_block[block].classList.add("secret"); + else + ui.battle_block[block].classList.remove("secret"); + if (moved) + ui.battle_block[block].classList.add("moved"); + else + ui.battle_block[block].classList.remove("moved"); + if (reserve) + ui.battle_block[block].classList.remove("known"); + else + ui.battle_block[block].classList.add("known"); + } + + for (let b in BLOCKS) { + if (ui.present.has(b)) { + if (!cell.contains(ui.battle_menu[b])) + cell.appendChild(ui.battle_menu[b]); + } else { + if (cell.contains(ui.battle_menu[b])) + cell.removeChild(ui.battle_menu[b]); + } + } + } + + if (ui.player == FRANK) { + fill_cell("FR", game.battle.FR, true); + fill_cell("FA", game.battle.FA, false); + fill_cell("FB", game.battle.FB, false); + fill_cell("FC", game.battle.FC, false); + fill_cell("EA", game.battle.SA, false); + fill_cell("EB", game.battle.SB, false); + fill_cell("EC", game.battle.SC, false); + fill_cell("ER", game.battle.SR, true); + } else { + fill_cell("ER", game.battle.FR, true); + fill_cell("EA", game.battle.FA, false); + fill_cell("EB", game.battle.FB, false); + fill_cell("EC", game.battle.FC, false); + fill_cell("FA", game.battle.SA, false); + fill_cell("FB", game.battle.SB, false); + fill_cell("FC", game.battle.SC, false); + fill_cell("FR", game.battle.SR, true); + } +} + +function on_update(state, player) { + game = state; + + show_action_button("#pass_button", "pass"); + show_action_button("#undo_button", "undo"); + show_action_button("#group_move_button", "group_move"); + show_action_button("#sea_move_button", "sea_move"); + show_action_button("#muster_button", "muster"); + show_action_button("#end_muster_button", "end_muster"); + show_action_button("#end_move_phase_button", "end_move_phase"); + show_action_button("#end_regroup_button", "end_regroup"); + show_action_button("#end_retreat_button", "end_retreat"); + show_action_button("#eliminate_button", "eliminate"); + + document.getElementById("frank_vp").textContent = game.f_vp; + document.getElementById("saracen_vp").textContent = game.s_vp; + + update_cards(); + update_map(); + + if (game.battle) { + document.querySelector(".battle_header").textContent = game.battle.title; + document.querySelector(".battle_message").textContent = game.battle.flash; + document.querySelector(".battle").classList.add("show"); + update_battle(player); + } else { + document.querySelector(".battle").classList.remove("show"); + } +} + +build_map(); + +document.querySelector(".map").classList.add(map_orientation); + +drag_element_with_mouse(".battle", ".battle_header"); +scroll_with_middle_mouse(".grid_center", 3); +init_map_zoom(); +init_shift_zoom(); +init_client(["Frank", "Saracen"]); -- cgit v1.2.3