This commit is contained in:
simon 2020-01-13 21:43:47 -05:00
parent 20abc7e978
commit d467bf0702
7 changed files with 858 additions and 108 deletions

4
.gitignore vendored
View File

@ -1,3 +1,5 @@
.idea/
*.iml
__pychache__
*.pyc
*.pyc
*.log

210
app.py
View File

@ -1,97 +1,175 @@
from threading import Lock
import logging
from flask import Flask, request, session
from flask_socketio import Namespace, join_room, leave_room, SocketIO, emit, rooms, close_room, disconnect
from flask import Flask, request, send_file, session, Response
from flask_socketio import Namespace, SocketIO, emit, join_room
from common import config
from common import config, db
from models import BingoGame, GameState, GameMode, User
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'
app.config['SECRET_KEY'] = config["FLASK_SECRET"]
# TODO: test mode mode "threading", "eventlet" or "gevent"
socketio = SocketIO(app, async_mode=None)
socketio = SocketIO(app, async_mode="eventlet")
logger = logging.getLogger("default")
# TODO: alphanum room
# TODO: alphanum name w/max len
# TODO: wth is that?!
thread = None
thread_lock = Lock()
@app.route("/")
def page_index():
return "Hello, world"
return send_file("web/index.html")
def background_thread():
"""Example of how to send server generated events to clients."""
count = 0
while True:
socketio.sleep(10)
count += 1
socketio.emit('my_response',
{'data': 'Server generated event', 'count': count},
namespace='/test')
@app.route("/<room>")
def play(room):
return send_file("web/room.html")
# When class-based namespaces are used, any events received by the server are dispatched to a method named as the
# event name with the on_ prefix. For example, event my_event will be handled by a method named on_my_event
def log(message, contents, room=None):
if config["VERBOSE"]:
logger.info("<%s|%s> [%s] %s:%s" % (
request.remote_addr, request.user_agent, room if room else "~", message, str(contents)))
else:
logger.info("[%s] %s:%s" % (room if room else "~", message, str(contents)))
class BingoNamespace(Namespace):
def __init__(self):
super().__init__("/socket")
def on_my_event(self, message):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': message['data'], 'count': session['receive_count']})
def on_get_end_message(self):
log("get_end_message", {})
emit("end_message", {
"text": "Game has ended, replay?"
})
def on_my_broadcast_event(self, message):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': message['data'], 'count': session['receive_count']},
broadcast=True)
def on_cell_click(self, message):
room = message["room"]
log("cell_click", message, room)
user = db.get_user(message["oid"])
card = db.get_card(message["card"])
cell = card.cells[message["cidx"]]
if not cell.checked or card.last_cell == message["cidx"]:
cell.checked = not cell.checked
card.last_cell = message["cidx"]
db.save_card(card)
emit("card_state", {
"card": card.serialize()
}, room=room)
if card.moves_until_win() == 0:
game = db.get_game(room)
game.winners.append(user.oid)
if game.should_end():
game.state = GameState.ENDED
emit("game_state", {"state": game.state.name})
db.save_game(game)
def on_get_card(self, message):
room = message["room"]
log("get_card", message, room)
user = db.get_user(message["oid"])
game = db.get_game(room)
if room in user.cards:
card = db.get_card(user.cards[room])
else:
card = game.generate_card()
user.cards[room] = card.oid
db.save_card(card)
db.save_user(user)
emit("card_state", {
"card": card.serialize(),
"parent": user.name
}, room=room)
emit("get_card_rsp", {
"card": card.serialize(),
"parent": user.name
})
for player in game.players:
if player != user.oid:
other_user = db.get_user(player)
if room in other_user.cards:
other_card = db.get_card(other_user.cards[room])
emit("card_state", {"card": other_card.serialize(), "parent": other_user.name})
def on_create_game(self, message):
room = message["room"]
log("create_game", message, room)
game = db.get_game(room)
if game.state is GameState.CREATING:
game.state = GameState.PLAYING
game.mode = GameMode[message["mode"]]
game.pool = message["pool"]
db.save_game(game)
emit("game_state", {
"state": game.state.name,
}, room=room)
emit("create_game_rsp", {
"created": True,
})
def on_join(self, message):
join_room(message['room'])
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': 'In rooms: ' + ', '.join(rooms()),
'count': session['receive_count']})
log("join", message)
def on_leave(self, message):
leave_room(message['room'])
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': 'In rooms: ' + ', '.join(rooms()),
'count': session['receive_count']})
room = message["room"]
def on_close_room(self, message):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response', {'data': 'Room ' + message['room'] + ' is closing.',
'count': session['receive_count']},
room=message['room'])
close_room(message['room'])
user = None
if "oid" in message:
user = db.get_user(message["oid"])
if not user:
emit("join_rsp", {
"ok": False
})
return
def on_my_room_event(self, message):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': message['data'], 'count': session['receive_count']},
room=message['room'])
if not user:
user = User(name=message["name"])
db.save_user(user)
session["user"] = user.oid
def on_disconnect_request(self):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': 'Disconnected!', 'count': session['receive_count']})
disconnect()
game = db.get_game(message["room"])
if not game:
game = BingoGame(room, user.oid)
def on_my_ping(self):
emit('my_pong')
join_room(room)
game.players.add(user.oid)
db.save_game(game)
# TODO: Is this useful?
emit("room_join", {
"name": user.name
}, room=room)
emit("join_rsp", {
"ok": True,
"state": game.state.name,
"oid": user.oid
})
def on_connect(self):
global thread
with thread_lock:
if thread is None:
thread = socketio.start_background_task(background_thread)
emit('my_response', {'data': 'Connected', 'count': 0})
pass
def on_disconnect(self):
print('Client disconnected', request.sid)
pass
socketio.on_namespace(BingoNamespace())
db.flush()
if __name__ == "__main__":
socketio.run(

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,7 +1,24 @@
import logging
import sys
from logging import FileHandler, StreamHandler
from models import DB
config = {
"API_PORT": 3000,
"API_HOST": "0.0.0.0",
"REDIS_HOST": "localhost",
"REDIS_PORT": 6379,
"FLASK_SECRET": "secret!",
"VERBOSE": False,
}
logger = logging.getLogger("default")
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s')
file_handler = FileHandler("bingo.log")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(StreamHandler(sys.stdout))
db = DB()

185
models.py
View File

@ -1,8 +1,11 @@
from enum import Enum
from uuid import uuid4
import random
import math
import redis
import json
from common import config
import common
class BingoCell:
@ -15,6 +18,9 @@ class BingoCell:
def serialize(self):
return self.__dict__
def __repr__(self):
return self.text
@staticmethod
def deserialize(j):
return BingoCell(
@ -24,67 +30,184 @@ class BingoCell:
)
class Row:
class BingoCard:
def __init__(self, size, cells=None, oid=None):
def __init__(self, size, cells=None, oid=None, last_cell=None):
if cells is None:
self.cells = []
else:
self.cells = cells
cells = []
self.cells = cells
if oid is None:
self.oid = uuid4().hex
else:
self.oid = oid
oid = uuid4().hex
self.oid = oid
self.size = size
self.last_cell = last_cell
def serialize(self):
return {
"oid": self.oid,
"cells": [c.serialize() for c in self.cells]
"cells": tuple(c.serialize() for c in self.cells),
"size": self.size,
"last_cell": self.last_cell,
"moves_until_win": self.moves_until_win(),
}
def __getitem__(self, col):
def moves_until_win(self):
return min(
*(sum(1 for c in self._row(row) if not c.checked) for row in range(0, self.size)),
*(sum(1 for c in self._col(col) if not c.checked) for col in range(0, self.size)),
sum(1 for c in self._diag_left() if not c.checked),
sum(1 for c in self._diag_right() if not c.checked),
)
def _row(self, idx):
return self.cells[idx * self.size:idx * self.size + self.size]
def _col(self, idx):
return [self.cells[c] for c in range(0, len(self.cells)) if c % self.size == idx]
def _diag_left(self):
return [self.cells[c] for c in range(0, len(self.cells), self.size + 1)]
def _diag_right(self):
return [self.cells[c] for c in range(self.size - 1, len(self.cells) - 1, self.size - 1)]
@staticmethod
def deserialize(text):
j = json.loads(text)
return BingoCard(cells=[
BingoCell.deserialize(c) for c in j["cells"]
])
def deserialize(j):
return BingoCard(
cells=tuple(BingoCell.deserialize(c) for c in j["cells"]),
size=j["size"],
oid=j["oid"],
last_cell=j["last_cell"]
)
class GameMode(Enum):
FREE = "free"
class GameState(Enum):
CREATING = "creating"
PLAYING = "playing"
ENDED = "ended"
class BingoGame:
def __init__(self, room, admin, mode=GameMode.FREE, pool=None, state=GameState.CREATING,
players=None, winners=None):
self.room = room
self.mode = mode
self.admin = admin
if pool is None:
pool = []
self.pool = pool
self.state = state
if players is None:
players = set()
self.players = players
if winners is None:
winners = []
self.winners = winners
def should_end(self):
# TODO: add winner count
return len(self.winners) > 0
def generate_card(self):
# TODO: customizable maximum size
size = math.floor(math.sqrt(len(self.pool)))
items = random.sample(self.pool, k=size * size)
return BingoCard(size, cells=[BingoCell(x) for x in items])
def serialize(self):
return {
"room": self.room,
"mode": self.mode.name,
"admin": self.admin,
"state": self.state.name,
"pool": self.pool,
"players": list(self.players),
"winners": self.winners,
}
@staticmethod
def deserialize(j):
return BingoGame(
room=j["room"],
mode=GameMode[j["mode"]],
pool=j["pool"],
admin=j["admin"],
state=GameState[j["state"]],
players=set(j["players"]),
winners=j["winners"]
)
class User:
def __init__(self, name, oid=None, cards=None):
if cards is None:
cards = {}
if oid is None:
oid = uuid4().hex
self.name = name
self.oid = oid
self.cards = cards
@staticmethod
def deserialize(j):
return User(
name=j["name"],
oid=j["oid"],
cards=j["cards"],
)
def serialize(self):
return self.__dict__
class DB:
_prefix = "bingo:"
def __init__(self):
self._rdb = redis.Redis(
host=config["REDIS_HOST"],
port=config["REDIS_PORT"]
host=common.config["REDIS_HOST"],
port=common.config["REDIS_PORT"]
)
def flush(self):
self._rdb.delete(self._rdb.keys(DB._prefix + "*"))
keys = self._rdb.keys(DB._prefix + "*")
if keys:
self._rdb.delete(*keys)
def _get(self, name):
return self._rdb.get(DB._prefix + name)
text = self._rdb.get(DB._prefix + name)
# print("<GET> %s = %s" % (name, text))
if text:
return json.loads(text)
def _set(self, name, value):
return self._rdb.set(DB._prefix + name, value)
self._rdb.set(DB._prefix + name, json.dumps(value, separators=(",", ":")))
# print("<SET> %s -> %s" % (name, value))
# self._rdb.expire(DB._prefix + name, 3600 * 24 * 14)
def get_card(self, oid):
return BingoCard.deserialize(self._get(oid))
j = self._get(oid)
if j:
return BingoCard.deserialize(j)
def save_card(self, card):
self._set(card.oid, card.serialize())
def get_game(self, room):
j = self._get("game:" + room)
if j:
return BingoGame.deserialize(j)
c = BingoCard(
size=4,
cells=[
BingoCell("test")
])
print(c.serialize())
def save_game(self, game: BingoGame):
self._set("game:" + game.room, game.serialize())
def get_user(self, oid):
j = self._get(oid)
if j:
return User.deserialize(j)
def save_user(self, user):
self._set(user.oid, user.serialize())

View File

@ -2,12 +2,67 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
<title>TODO: title</title>
<style>
body, html {
text-align: center;
padding: 0;
margin: 0;
}
#room-prompt {
height: 50%;
overflow: hidden;
margin: auto;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
input {
line-height: 3;
padding: 0.2rem 0.5rem;
}
.info {
float: right;
padding: 0.5rem;
}
</style>
</head>
<body>
<div id="room-prompt">
<h1>Bingo</h1>
<form onsubmit="onSubmit(); return false">
<input id="room-input" type="text" placeholder="Join/create room">
</form>
</div>
<div class="info">
TODO: info
</div>
<script>
function isAlphanumeric(c) {
return "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_".indexOf(c) > -1;
}
window.onload = function () {
const input = document.getElementById("room-input");
input.addEventListener("keydown", e => {
if (!isAlphanumeric(e.key) && e.key !== "Backspace" && e.key !== "Enter") {
e.preventDefault();
}
})
}
function onSubmit() {
const room = document.getElementById("room-input").value;
window.location = "/" + room;
}
</script>
</body>
</html>

484
web/room.html Normal file
View File

@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
<style>
* {
padding: 0;
margin: 0;
overflow: hidden;
}
.modal {
display: none;
position: fixed;
z-index: 1;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
}
.modal-content {
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 1200px;
}
</style>
</head>
<body>
<div id="game"></div>
<script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<script src="https://pixijs.download/release/pixi.min.js"></script>
<div id="create-game" class="modal">
<div class="modal-content">
<h3>Create game</h3>
<form onsubmit="return onCreateGameSubmit()">
<label>
Game mode
<select id="game-mode">
<option value="FREE">Free</option>
<option value="ADMIN" disabled>Admin</option>
<option value="CPU" disabled>CPU</option>
</select>
</label>
<br>
<label>
Maximum size
<select id="maximum-size">
<option selected value="0">Automatic</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</label>
<br>
<label>
Word/Phrases (one per line)
<br>
<textarea id="pool" cols="70" rows="10" placeholder="Words/Phrases (one per line)"></textarea>
</label>
<br>
<label>
Middle cell is free
<input id="middle-free" type="checkbox">
</label>
<br>
<input type="submit" value="Create game">
</form>
</div>
</div>
<div id="create-user" class="modal">
<div class="modal-content">
<h3>New player</h3>
<form onsubmit="return onCreateUserSubmit()">
<label>
Name
<input type="text" id="name">
</label>
<br>
<input type="submit" value="Join room">
</form>
</div>
</div>
<script>
const socket = io("/socket");
const ROOM = window.location.pathname.slice(1);
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;
const PORTRAIT = window.innerWidth < window.innerHeight;
let CARDS = {};
let COLS, ROWS;
if (PORTRAIT) {
COLS = 3;
ROWS = 2;
} else {
COLS = 2;
ROWS = 3;
}
let NEXT_ROW = [0, 0];
let NEXT_COL = [0, 0];
let NEXT_SIDE = 0;
//TODO: refact/extract to maskInputAlpha() or someth
const input = document.getElementById("name");
input.addEventListener("keydown", e => {
if (!isAlphanumeric(e.key) && e.key !== "Backspace" && e.key !== "Enter") {
e.preventDefault();
}
})
function createGameModal() {
document.getElementById("create-game").style.display = "block";
}
function onCreateGameSubmit() {
const gameMode = document.getElementById("game-mode").value;
const pool = document.getElementById("pool").value;
const maximumSize = document.getElementById("maximum-size").value;
const middleFree = document.getElementById("middle-free").value;
socket.emit("create_game", {
"oid": selfOid(),
"room": ROOM,
"mode": gameMode,
"maximum_size": maximumSize,
"middle_free": middleFree,
"pool": pool.split(/\s+/).map(w => w.trim())
})
return false;
}
function selfOid() {
return localStorage.getItem("oid")
}
function selfName() {
return localStorage.getItem("name")
}
socket.on("message", msg => {
TEXT._display(msg.text, msg.timeout)
})
socket.on("end_message", msg => {
alert(msg.text)
})
socket.on("game_state", msg => {
if (msg.state === "PLAYING") {
document.getElementById("create-game").style.display = "none";
socket.emit("get_card", {
"oid": selfOid(),
"room": ROOM,
})
} else if (msg.state === "ENDED") {
socket.emit("get_end_message")
}
})
socket.on("card_state", msg => {
if (CARDS.hasOwnProperty("SELF")) {
if (CARDS.hasOwnProperty(msg.card.oid)) {
CARDS[msg.card.oid]._update(msg.card.cells)
} else {
// Add other card
let card = new BingoCard(msg.card.oid, msg.parent, XSCALE, YSCALE);
card._update(msg.card.cells)
app.stage.addChild(card);
CARDS[msg.card.oid] = card;
NEXT_SIDE = (Object.keys(CARDS).length - 1) % 2
card.x = (CARD_WIDTH * XSCALE + CARD_PAD) * NEXT_COL[NEXT_SIDE] + CARD_PAD;
card.y = (CARD_HEIGHT * YSCALE + CARD_PAD) * NEXT_ROW[NEXT_SIDE] + CARD_PAD;
if (NEXT_SIDE === 1) {
card.x += WIDTH * (2 / 3) - CARD_PAD / 2
}
if (NEXT_COL[NEXT_SIDE] === COLS - 1) {
NEXT_COL[NEXT_SIDE] = 0;
NEXT_ROW[NEXT_SIDE] += 1;
} else {
NEXT_COL[NEXT_SIDE] += 1;
}
}
}
})
socket.on("get_card_rsp", msg => {
// Add self card
let card = new BingoCard(msg.card.oid, msg.parent, 1.0);
card._update(msg.card.cells)
card.x = WIDTH / 2 - (CARD_WIDTH / 2);
card.y = HEIGHT / 2 - (CARD_HEIGHT / 2);
app.stage.addChild(card);
CARDS[msg.card.oid] = card;
CARDS["SELF"] = card;
})
function createUser() {
document.getElementById("create-user").style.display = "block";
}
function isAlphanumeric(c) {
return "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_".indexOf(c) > -1;
}
function onCreateUserSubmit() {
const name = document.getElementById("name").value;
localStorage.setItem("name", name)
socket.emit("join", {
room: ROOM,
name: name
});
return false;
}
socket.on("join_rsp", msg => {
if (msg.ok === false) {
createUser();
return;
}
document.getElementById("create-user").style.display = "none";
localStorage.setItem("oid", msg.oid)
document.title = msg.oid
if (msg.state === "CREATING") {
createGameModal();
} else if (msg.state === "PLAYING") {
socket.emit("get_card", {
"oid": selfOid(),
"room": ROOM,
})
} else if (msg.state === "ENDED") {
socket.emit("get_end_message")
}
});
socket.on("connect", () => {
let oid = selfOid();
if (oid) {
socket.emit("join", {
room: ROOM,
name: selfName(),
oid: oid,
});
} else {
createUser();
}
});
socket.on("room_join", msg => {
console.log(msg);
})
const app = new PIXI.Application(
{antialias: false, width: WIDTH, height: HEIGHT, resolution: 2, resizeTo: window}
);
document.body.appendChild(app.view);
app.renderer.autoDensity = true;
app.renderer.resize(window.innerWidth, window.innerHeight);
let CARD_WIDTH, CARD_HEIGHT;
if (PORTRAIT) {
CARD_WIDTH = 0.65 * WIDTH;
CARD_HEIGHT = (1 / 3) * HEIGHT;
} else {
CARD_WIDTH = (1 / 3) * WIDTH;
CARD_HEIGHT = 0.70 * HEIGHT;
}
const CELL_COLOR = 0x00FF00;
const CELL_COLOR_CHECKED = 0xFF0000;
const CELL_COLOR_HOVER = 0x00FFFF;
const CELL_PAD = 4;
const CARD_PAD = 20;
function makeCell(cell, size, card_oid, xscale, yscale) {
const g = new PIXI.Graphics();
if (xscale === 1) {
g.interactive = true;
g.buttonMode = true;
g.on("mouseover", () => {
g._color = CELL_COLOR_HOVER;
g._update();
})
g.on("mouseout", () => {
g._color = g._baseColor;
g._update();
})
g.on("click", () => {
socket.emit("cell_click", {
"oid": selfOid(),
"cidx": cell.cidx,
"card": card_oid,
"room": ROOM
})
})
}
g._baseColor = cell.checked ? CELL_COLOR_CHECKED : CELL_COLOR;
g._color = g._baseColor;
g._update = function () {
g.clear();
g.beginFill(this._color);
g.drawRect(0, 0,
((xscale * CARD_WIDTH - CELL_PAD) / size - CELL_PAD),
((yscale * CARD_HEIGHT - CELL_PAD) / size - CELL_PAD)
);
g.endFill()
if (g.children.length === 0) {
const maxWidth = g.width - 4;
const maxHeight = g.height - 4;
const text = new PIXI.Text(cell.text,
{
fontFamily: 'Hack',
fontSize: xscale === 1 ? 16 : 8,
fill: 0x000000,
align: "center",
breakWords: true,
wordWrap: true,
wordWrapWidth: maxWidth,
}
);
text.anchor.set(0.5, 0.5)
text.x = g.width / 2;
text.y = g.height / 2;
//TODO: Adjust text size
if (text.height < maxHeight) {
g.addChild(text);
}
}
}
g._update()
return g
}
function BingoCard(oid, parent, xscale = 1, yscale = 1) {
const COLOR = 0x5cafe2;
let g = new PIXI.Graphics();
g.setMatrix(new PIXI.Matrix().scale(xscale, yscale));
g.lineStyle(3, COLOR);
g.drawRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
const text = new PIXI.Text(parent, {
fontFamily: 'Hack',
fontSize: 16,
fill: 0xFFFFFF,
align: "center",
strokeThickness: 3
}
);
text.anchor.set(0.5, 0.35)
text.x = g.width / 2;
text.y = g.height;
g.addChild(text);
g._update = function (cells) {
g.children.forEach(child => {
if (child !== text) {
child.destroy();
}
})
let size = Math.floor(Math.sqrt(cells.length))
for (let col = 0; col < size; col++) {
for (let row = 0; row < size; row++) {
let cidx = col * size + row;
let cell = cells[cidx];
cell.cidx = cidx;
let c = makeCell(cell, size, oid, xscale, yscale)
c.x = (c.width + CELL_PAD) * row + CELL_PAD;
c.y = (c.height + CELL_PAD) * col + CELL_PAD;
g.addChild(c);
}
}
}
return g;
}
function makeText() {
const PAD = 5;
const t = new PIXI.Text("", {
fontFamily: 'Hack',
fontSize: 38,
fill: 0xFFFFFF,
strokeThickness: 2,
align: "left",
breakWords: true,
wordWrap: true,
wordWrapWidth: WIDTH / 3 - PAD * 2,
});
t.x = WIDTH / 2;
t.y = PORTRAIT ? HEIGHT / 2 : HEIGHT / 12;
t.anchor.set(0.5, 0.5)
t._display = function(text, timeout) {
app.stage.children.sort((a,b) => {
return a === t ? 1 : 0;
})
t.text = text
if (t._to) {
window.clearTimeout(t._to);
}
t._to = window.setTimeout(() => {
t.text = ""
}, timeout)
}
return t;
}
let TEXT = makeText();
app.stage.addChild(TEXT);
let XSCALE, YSCALE;
if (PORTRAIT) {
XSCALE = (WIDTH) / ((CARD_WIDTH + CARD_PAD * 2) * COLS + CARD_PAD * 2)
YSCALE = (HEIGHT / 3) / ((CARD_HEIGHT + CARD_PAD * 4) * ROWS + CARD_PAD * 4)
} else {
XSCALE = (WIDTH / 3) / ((CARD_WIDTH + CARD_PAD * 2) * COLS + CARD_PAD * 2)
YSCALE = (HEIGHT) / ((CARD_HEIGHT + CARD_PAD * 4) * ROWS + CARD_PAD * 4)
}
</script>
</body>
</html>