diff --git a/game.html b/game.html index 639db8a..1c8b587 100644 --- a/game.html +++ b/game.html @@ -3,8 +3,8 @@ KoiKoi - - + +
diff --git a/js/GUI/Card.js b/js/GUI/Card.js new file mode 100644 index 0000000..8bb9acf --- /dev/null +++ b/js/GUI/Card.js @@ -0,0 +1,21 @@ +import Hanafuda; +import * as Dom from UnitJS.Dom; + +function Card(name) { + this.value = Hanafuda.Card[name]; + this.name = name; + this.dom = Dom.make('li', { + class: [ + "card", + "value" + Hanafuda.getValue(this.value), + "month" + this.value.flower + ], + onClick: this.onClick() + }); +} + +Card.prototype.onClick = function() {return function() {};}; + +return { + Card: Card +}; diff --git a/js/GUI/Card/HandCard.js b/js/GUI/Card/HandCard.js new file mode 100644 index 0000000..d7043e2 --- /dev/null +++ b/js/GUI/Card/HandCard.js @@ -0,0 +1,28 @@ + +function HandCard() { + Card.apply(this, arguments); +} + +HandCard.prototype.onClick = function() { + var card = this; + return function() { + if(status.playing && status.step == "ToPlay") { + if(selected != undefined) { + selected.setSelected(false); + } else { + card.play(); + } + } + }; +}; + +HandCard.prototype.setSelected = setSelected; + +HandCard.prototype.play = function() { + var matching = matchingInRiver(this.value); + if(matching.length > 1) { + this.setSelected(true); + } else { + play({play: this.name}); + } +} diff --git a/js/GUI/Card/RiverCard.js b/js/GUI/Card/RiverCard.js new file mode 100644 index 0000000..b4335d5 --- /dev/null +++ b/js/GUI/Card/RiverCard.js @@ -0,0 +1,27 @@ + +function RiverCard() { + Card.apply(this, arguments); + this.candidate = false; +} + +RiverCard.prototype.onClick = function() { + var card = this; + return function() { + if(card.candidate) { + var withCard = selected.name; + selected.setSelected(false); + play( + status.step == 'ToPlay' ? {capture: [withCard, card.name]} : {choose: card.name} + ); + } + }; +}; + +RiverCard.prototype.setCandidate = function(yes) { + this.candidate = yes; + this.dom.classList.toggle("candidate", yes); +} + +return { + RiverCard: RiverCard +}; diff --git a/js/GUI/Card/TurnedCard.js b/js/GUI/Card/TurnedCard.js new file mode 100644 index 0000000..8f34546 --- /dev/null +++ b/js/GUI/Card/TurnedCard.js @@ -0,0 +1,13 @@ + +function TurnedCard() { + Card.apply(this, arguments); + this.dom.id = "turned"; + deck.appendChild(this.dom); +} + +TurnedCard.prototype.onClick = Card.prototype.onClick; +TurnedCard.prototype.setSelected = setSelected; + +return { + TurnedCard: TurnedCard +}; diff --git a/js/GUI/Screen/Game.js b/js/GUI/Screen/Game.js new file mode 100644 index 0000000..c381782 --- /dev/null +++ b/js/GUI/Screen/Game.js @@ -0,0 +1,343 @@ +import Hanafuda; +import I18n; +import Messaging; +import players from Room; +import * as Screen from GUI.Screen; +import Session; +import StatusHandler; +import * as Async from UnitJS.Async; +import * as Dom from UnitJS.Dom; +import * as Fun from UnitJS.Fun; + +var deck = document.getElementById("deck"); +var rest = document.getElementById("rest"); +var status = { + dom: document.getElementById("status"), + game: null, + playing: false, + step: null, + month: null +}; +var sets; +var selected = null; +var turnedCard = null; +var queue = []; + +return { + init: init +} + +function init(state) { + sets = buildSets(); + window.addEventListener('focus', runQueue); + Messaging.addEventListener(["Game"], function(o) { + queue.push(handleGameMessage(o)); + if(document.hasFocus() && queue.length == 1) { + runQueue(); + } else { + StatusHandler.set("♪"); + } + }); + Async.run(setGame({tag: "Game", state: state, logs: []})); +} + +function buildSets() { + var sets = {}; + ['river', 'you', 'them'].forEach(function(id) { + var dom = document.getElementById(id); + if(dom.tagName.toLowerCase() == 'ul') { + sets[id] = {card: null, dom: dom}; + } else { + sets[id] = {}; + for(var i = 0; i < dom.children.length; i++) { + if(dom.children[i].tagName.toLowerCase() == 'ul') { + sets[id][dom.children[i].className] = {card: {}, dom: dom.children[i]}; + } + } + } + }); + return sets; +} + +function runQueue() { + if(queue.length > 0) { + var length = queue.length; + Async.run.apply(null, queue.concat( + Async.apply(function() { + queue = queue.slice(length); + runQueue(); + }) + )); + } +} + +function handleGameMessage(o) { + if(o.state.public.turns == 0) { + if(o.logs.length > 0) { // but still some logs, from the previous round + return Async.sequence(applyDiff(o), setGame(o)); // so play the diff, then set the new round + } else { + return setGame(o); // directly set a whole new game + } + } else { + return applyDiff(o); + } +} + +function setGame(o) { + return function(f) { + setStatus(o.state); + setCaptures(o.state); + [ + [sets.river, o.state.public.river, RiverCard], + [sets.you.hand, o.state.playerHand, HandCard] + ].forEach(function(args) {setCardSet.apply(null, args)}); + setTheirCards(o.state); + handleStep(o)(f); + }; +} + +function handleStep(o) { + return function(f) { + handleTurnedCard(o, f); + if(status.step == "Scored") { + if(status.playing) { + askKoikoi(o, f); + } else { + theyScored(o, f); + } + } else if (status.step == "Over") { + gameEnd(o, f); + } else { + f(); + } + }; +} + +function handleTurnedCard(o, f) { + if(status.step == "Turned") { + setTurned(o.state.public.step.contents); + } else { + if(status.step == "ToPlay" && o.state.public.playing == o.state.public.oyake) { + rest.className = ["card", "turn" + o.state.public.turns].join(' '); + } + if(deck.lastChild.id != "rest") { + deck.removeChild(deck.lastChild); + } + } +} + +function askKoikoi(o, f) { + Screen.dialog({ + text: I18n.get('youScored'), + answers: [ + {label: 'endRound', action: function() {play({koiKoi: false}); f();}}, + {label: 'koikoi', action: function() {play({koiKoi: true}); f();}} + ] + }); + +} + +function theyScored(o, f) { + Screen.dialog({ + text: I18n.get('theyScored')(players.get(o.state.public.playing)), + answers: [ + {label: 'ok', action: f} + ] + }); +} + +function gameEnd(o, f) { + var winner, maxScore; + for(var key in o.state.public.scores) { + if(maxScore == undefined || o.state.public.scores[key] > maxScore) { + winner = key; + maxScore = o.state.public.scores[key]; + } + } + Screen.dialog({ + text: I18n.get(Session.is(winner) ? 'won' : 'lost'), + answers: [{ + label: 'endGame', + action: function() { + Messaging.send({tag: "Quit"}); + Screen.select('reception'); + f(); + } + }] + }); +} + +function applyDiff(o) { + return Async.sequence.apply(null, + o.logs.map(animate).concat( + Async.apply(setStatus, o.state), + handleStep(o) + ) + ); +} + +function animate(movement) { + return Async.bind( + Async.apply(function() { + var card; + var movingCards = []; + var side = (status.playing) ? 'you' : 'them'; + var dest = sets.river; + if(movement.captures != undefined) { + card = new Card(movement.played); + dest = sets[side]; + movingCards.push([sets.river, dest, new Card(movement.captures)]); + } else { + card = new RiverCard(movement.played); + } + if(movement.source == 'Hand') { + movingCards.push([sets[side].hand, dest, card]); + } else { + var cardSet = {}; + cardSet[card.name] = turnedCard || new TurnedCard(card.name); + turnedCard = null; + movingCards.push([{card: cardSet, dom: deck}, dest, card]); + } + return movingCards; + }), + function(movingCards) { + return Async.parallel.apply(null, + movingCards.map(function(args) { return moveCard.apply(null, args); }) + ); + } + ); +} + +function moveCard(fromSet, toSet, card) { + var from, originalCard; + var slot = Dom.make('li', {class: ['card', 'slot']}); + if (fromSet.card[card.name] != undefined) { + originalCard = fromSet.card[card.name].dom; + delete fromSet.card[card.name]; + } else { + var originalCard = fromSet.dom.children[fromSet.dom.children.length - 1]; + } + from = originalCard.getBoundingClientRect(); + fromSet.dom.replaceChild(slot, originalCard); + card.dom.style.visibility = 'hidden'; + insertCard(toSet, card); + var to = card.dom.getBoundingClientRect(); + card.dom.style.left = (from.left - to.left) + 'px'; + card.dom.style.top = (from.top - to.top) + 'px'; + card.dom.classList.add('moving'); + card.dom.style.visibility = null; + return Async.sequence( + Async.wait(10), + Async.apply(function() { + card.dom.style.left = 0; + card.dom.style.top = 0; + }), + Async.wait(1000), + Async.apply(function() { + fromSet.dom.removeChild(slot); + card.dom.classList.remove('moving'); + }) + ); +} + +function insertCard(toSet, card) { + if(toSet.dom != undefined) { + toSet.card[card.name] = card; + toSet.dom.appendChild(card.dom); + } else { + insertCard(toSet[card.value.family.class], card); + } +} + +function play(move) { + Messaging.send({ + tag: "Play", + move: move, + onGame: status.game + }); +} + +function matchingInRiver(card) { + return Fun.mapFilter( + Fun.of(sets.river.card), + Fun.defined + )(Hanafuda.sameMonth(card).map(Fun.proj('name'))); +} + +function setStatus(game) { + Dom.clear(status.dom); + status.game = game; + status.step = game.public.step.tag; + if(game.public.month != status.month) { + status.month = game.public.month; + } + status.dom.appendChild( + Dom.make('li', {textContent: I18n.get('monthFlower')(I18n.get(status.month))}) + ); + var turn = null; + status.playing = Session.is(game.public.playing); + if(status.playing) { + sets.you.hand.dom.classList.toggle("yourTurn", status.step == "ToPlay"); + turn = I18n.get("yourTurn"); + } else { + sets.you.hand.dom.classList.remove("yourTurn"); + turn = I18n.get('playing')(players.get(game.public.playing)); + } + status.dom.appendChild(Dom.make('li', {textContent: turn})); +} + +function setCaptures(game) { + for(var key in game.public.players) { + var elem = document.getElementById(Session.is(key) ? "you" : "them"); + elem.getElementsByClassName('score')[0].textContent = game.public.scores[key] + " pts"; + var byClass = {} + Object.values(Hanafuda.Family).forEach(function(family) { + byClass[family.class] = elem.getElementsByClassName(family.class)[0]; + Dom.clear(byClass[family.class]); + }); + game.public.players[key].meld.forEach(function(cardName) { + var card = new Card(cardName); + byClass[card.value.family.class].appendChild(card.dom); + }); + } +} + +function setCardSet(set, cardNames, constructor) { + constructor = constructor || Card; + set.card = {}; + Dom.clear(set.dom); + cardNames.forEach(function(cardName) { + var card = new constructor(cardName); + set.card[cardName] = card; + set.dom.appendChild(card.dom); + }); +} + +function setTheirCards(game) { + var turnsTheyPlayed = Math.floor( + (game.public.turns + (Session.is(game.public.oyake) ? 0 : 1)) / 2 + ); + Dom.clear(sets.them.hand.dom); + for(var i = 0; i < 8 - turnsTheyPlayed; i++) { + sets.them.hand.dom.appendChild(Dom.make('li', {class: "card"})); + } +} + +function setTurned(cardName) { + turnedCard = new TurnedCard(cardName); + if(status.playing) { + selected = turnedCard; + showCandidates(Hanafuda.Card[cardName], true); + } +} + +function showCandidates(card, yes) { + matchingInRiver(card).forEach(function(riverCard) {riverCard.setCandidate(yes);}); +} + +function setSelected(yes) { + selected = yes ? this : null; + this.dom.classList.toggle('selected', yes); + showCandidates(this.value, yes); +} + diff --git a/js/GUI/Screen/Hall.js b/js/GUI/Screen/Hall.js index 319505a..99f9062 100644 --- a/js/GUI/Screen/Hall.js +++ b/js/GUI/Screen/Hall.js @@ -11,6 +11,8 @@ return { }; function init() { + Players.init(); + GamesGUI.init(); Messaging.addEventListener(["Okaeri"], function(o) { refresh(); }); diff --git a/js/GUI/Screen/Hall/Games.js b/js/GUI/Screen/Hall/Games.js index 85b6192..9ebb1fa 100644 --- a/js/GUI/Screen/Hall/Games.js +++ b/js/GUI/Screen/Hall/Games.js @@ -6,12 +6,17 @@ import players from Room; import dialog from GUI.Screen; import * as Dom from UnitJS.Dom; -var list = ListSelector.make('games', showGame); +var list; return { + init: init, refresh: refresh }; +function init() { + list = ListSelector.make('games', showGame); +} + function showGame(game) { var liContent; if(game.key.match(/^Player#/)) { // Game proposals use the ID of the opponent as ID @@ -47,7 +52,7 @@ function pendingGame(game) { Dom.make('span', { textContent: status}), Dom.make('a', { textContent: I18n.get('pendingGame')(game.value.yourTurn, game.value.vs.name), - href: '/game/' + game.key + href: '/game/' + game.key.replace(/Game#/, '') }) ]; } diff --git a/js/GUI/Screen/Hall/Players.js b/js/GUI/Screen/Hall/Players.js index 21bbcf1..81d813a 100644 --- a/js/GUI/Screen/Hall/Players.js +++ b/js/GUI/Screen/Hall/Players.js @@ -7,16 +7,18 @@ import Messaging; import players from Room; import * as Dom from UnitJS.Dom; -var form = ConnectedForm.get('room'); -var list = ListSelector.make('players', showPlayer); -var them = null; -initDOM(); +var form; +var list; +var them; return { + init: init, refresh: refresh }; -function initDOM() { +function init() { + form = ConnectedForm.get('room'); + list = ListSelector.make('players', showPlayer); form.root.getElementsByTagName('label')[0].textContent = I18n.get('startGameWith'); form.root.invite.value = I18n.get('invite'); form.root.addEventListener('submit', function(e) { diff --git a/js/GUI/Screen/Login.js b/js/GUI/Screen/Login.js index 397576c..3a001c2 100644 --- a/js/GUI/Screen/Login.js +++ b/js/GUI/Screen/Login.js @@ -5,14 +5,16 @@ import Messaging; import Session; import Save; -var login = GUI.ConnectedForm.get('login'); -var form = login.root; +var login; +var form; return { init: init }; function init() { + login = GUI.ConnectedForm.get('login'); + form = login.root; initDOM(); initMessageHandlers(); var name = Save.get('player.name'); diff --git a/js/Games.js b/js/Games.js index 280de84..45f9290 100644 --- a/js/Games.js +++ b/js/Games.js @@ -1,5 +1,5 @@ import Messaging; -import opponent from Room; +import player from Room; import Save; import Session; import Table; @@ -17,7 +17,7 @@ return { function makeEntry(state) { var sessionKey = Session.getKey(); return Time.timestamp({ - vs: opponent(state.public.nextPlayer[sessionKey]), + vs: player(state.public.nextPlayer[sessionKey]), yourTurn: state.public.playing == sessionKey }); } @@ -25,7 +25,7 @@ function makeEntry(state) { function proposal(playerID, yourTurn) { entries.insert( playerID, - Time.timestamp({vs: opponent(playerID), yourTurn: yourTurn}) + Time.timestamp({vs: player(playerID), yourTurn: yourTurn}) ); } diff --git a/js/Hanafuda.js b/js/Hanafuda.js new file mode 100644 index 0000000..c312afa --- /dev/null +++ b/js/Hanafuda.js @@ -0,0 +1,78 @@ +var Flower = Object.freeze({ + Pine: 0, + Plum: 1, + Cherry: 2, + Wisteria: 3, + Iris: 4, + Peony: 5, + BushClover: 6, + SusukiGrass: 7, + Chrysanthemum: 8, + Maple: 9, + Willow: 10, + Paulownia: 11 +}); + +var Family = Object.freeze( + ["Kasu", "Tan", "Tane", "Hikari"].reduce(function(o, name) { + o[name] = {class: name[0].toLowerCase() + name.slice(1)}; + return o; + }, {}) +); + +var CardNames = [ + "Pine0", "Pine1", "PinePoetry", "Crane", + "Plum0", "Plum1", "PlumPoetry", "BushWarbler", + "Cherry0", "Cherry1", "CherryPoetry", "CampCurtain", + "Wisteria0", "Wisteria1", "WisteriaRed", "Cuckoo", + "Iris0", "Iris1", "IrisRed", "EightPlankBridge", + "Peony0", "Peony1", "PeonyBlue", "Butterflies", + "BushClover0", "BushClover1", "BushCloverRed", "Boar", + "SusukiGrass0", "SusukiGrass1", "Geese", "FullMoon", + "Chrysanthemum0", "Chrysanthemum1", "ChrysanthemumBlue", "SakeCup", + "Maple0", "Maple1", "MapleBlue", "Deer", + "Lightning", "WillowRed", "Swallow", "RainMan", + "Paulownia0", "Paulownia1", "Sand", "Phoenix" +]; + +var Card = Object.freeze( + CardNames.reduce(function(o, name, i) { + o[name] = { + id: i, + family: findFamily(name, i), + flower: Math.floor(i / 4), + name: name + }; + return o; + }, {}) +); + +return { + Flower: Flower, + Family: Family, + Card: Card, + getValue: getValue, + sameMonth: sameMonth +}; + +function findFamily(name, i) { + if((i % 4 < 2 && i < 41) || (i > 43 && i < 47)) { + return Family.Kasu; + } else if(name.match(/(Blue|Poetry|Red)/)) { + return Family.Tan; + } else if(["Crane", "CampCurtain", "FullMoon", "RainMan", "Phoenix"].includes(name)) { + return Family.Hikari; + } else { + return Family.Tane; + } +} + +function getValue(card) { + var first = 4 * card.flower; + return card.id - first; +} + +function sameMonth(card) { + var first = 4 * card.flower; + return [0,1,2,3].map(function(i) {return Card[CardNames[first + i]];}); +} diff --git a/js/Main.js b/js/Main.js index 103d5b5..b8fc9a2 100644 --- a/js/Main.js +++ b/js/Main.js @@ -1,5 +1,25 @@ +import I18n; +import * as Screen from GUI.Screen; import * as Login from GUI.Screen.Login; import * as Hall from GUI.Screen.Hall; +import * as Game from GUI.Screen.Game; +import Save; -Login.init(); -Hall.init(); +var gamePath = window.location.pathname.match(/\/game\/(?[0-9A-Fa-f]+)/); + +if(gamePath) { + var gameState = Save.get('games.state.Game#' + gamePath.groups.id); + if(gameState != undefined) { + Game.init(gameState); + } else { + Screen.dialog({ + text: I18n.get('gameNotFound'), + answers: [ + {label: 'backToMain', action: function() {window.location = '..';}} + ] + }); + } +} else { + Login.init(); + Hall.init(); +} diff --git a/js/Room.js b/js/Room.js index 07d90cd..72cf0f7 100644 --- a/js/Room.js +++ b/js/Room.js @@ -6,11 +6,11 @@ var players = Table.make(function(o) {return o.value}); initMessageHandlers(); return { - opponent: opponent, + player: player, players: players }; -function opponent(key) { +function player(key) { return {id: key, name: players.get(key)}; } diff --git a/js/Translations.js b/js/Translations.js index c2b4a7d..35e0021 100644 --- a/js/Translations.js +++ b/js/Translations.js @@ -14,9 +14,11 @@ return { Wisteria: "wisteria", accept: "Let's go !", alone: "No one to play with yet ! Wait a little", + backToMain: "Back to main menu", decline: "No thanks", endRound: "End the round", endGame: "Return to main menu", + gameNotFound: "You followed a fishy link, this game is no more", join: "Join", invite: "Invite", koikoi: "KoiKoi !!", @@ -72,9 +74,11 @@ return { Wisteria: "glycines", accept: "C'est parti !", alone: "Personne pour jouer pour l'instant ! Attendez un peu", + backToMain: "Retourner au menu principal", decline: "Non merci", endRound: "Finir la manche", endGame: "Retourner au menu principal", + gameNotFound: "Ce lien est louche, la partie n'est plus", join: "Entrer", invite: "Inviter", koikoi: "KoiKoi !!",