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 !!",