Compare commits
No commits in common. "main" and "feature/mahjong-scorecard" have entirely different histories.
main
...
feature/ma
|
|
@ -23,5 +23,4 @@ jobs:
|
||||||
make
|
make
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
npm install netlify-cli --save-dev
|
netlify deploy --dir target --prod
|
||||||
npx netlify deploy --dir target --prod
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1 @@
|
||||||
target/
|
target/
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Finska Score Card - Tools</title>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
|
|
||||||
>
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
<script src="./scripts/main.js" type="module"></script>
|
|
||||||
</head>
|
|
||||||
<body class="container">
|
|
||||||
<h1>Finska Scorecard</h1>
|
|
||||||
|
|
||||||
<div data-controller="finska-scorecard"
|
|
||||||
data-finska-scorecard-max-score-value="50"
|
|
||||||
data-finska-scorecard-overflow-score-to-value="25">
|
|
||||||
<table class="score-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">Team A</td>
|
|
||||||
<td colspan="2">Team B</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody data-finska-scorecard-target="scoreTable">
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score1Input" data-action="keydown->finska-scorecard#score1KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score2Input" data-action="keydown->finska-scorecard#score2KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="action-bar">
|
|
||||||
<button data-action="finska-scorecard#undoLast">
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
<button data-action="finska-scorecard#resetAll">
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { Scorecard, getStoreDAO } from "./models.js";
|
|
||||||
|
|
||||||
const storeDao = getStoreDAO();
|
|
||||||
|
|
||||||
export class FinskaScorecardController extends Controller {
|
|
||||||
static targets = ["score1Input", "score2Input", "scoreTable"];
|
|
||||||
static values = {
|
|
||||||
maxScore: Number,
|
|
||||||
overflowScoreTo: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
let rules = {
|
|
||||||
maxScore: Math.floor(this.maxScoreValue),
|
|
||||||
overflowScoreTo: Math.floor(this.overflowScoreToValue)
|
|
||||||
};
|
|
||||||
|
|
||||||
this._undoStack = [];
|
|
||||||
this._scorecard = storeDao.loadOrCreate(rules);
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTable() {
|
|
||||||
let tableBody = this.scoreTableTarget;
|
|
||||||
let tableRows = tableBody.querySelectorAll("tr");
|
|
||||||
let pairs = this._scorecard.pairs();
|
|
||||||
|
|
||||||
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
|
||||||
let tableRow;
|
|
||||||
if (pairIndex >= tableRows.length) {
|
|
||||||
tableRow = this._appendRow();
|
|
||||||
} else {
|
|
||||||
tableRow = tableRows[pairIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
this._updateRow(tableRow, pairs[pairIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any extra rows
|
|
||||||
for (let i = pairs.length; i < tableRows.length; i++) {
|
|
||||||
tableBody.removeChild(tableRows[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(this._scorecard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateRow(tableRow, pair) {
|
|
||||||
let tds = tableRow.querySelectorAll("td");
|
|
||||||
|
|
||||||
this._updateCell(pair.p1, tds[0], tds[1]);
|
|
||||||
this._updateCell(pair.p2, tds[2], tds[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateCell(score, scoreCell, totalCell) {
|
|
||||||
scoreCell.classList.value = "";
|
|
||||||
totalCell.classList.value = "";
|
|
||||||
|
|
||||||
if (score != null) {
|
|
||||||
scoreCell.textContent = score.score;
|
|
||||||
totalCell.textContent = score.total;
|
|
||||||
|
|
||||||
if (score.score === 0) {
|
|
||||||
scoreCell.classList.add("score-foul");
|
|
||||||
totalCell.classList.add("score-foul");
|
|
||||||
} else if (score.wasOverflow) {
|
|
||||||
scoreCell.classList.add("score-overflow");
|
|
||||||
totalCell.classList.add("score-overflow");
|
|
||||||
} else if (score.wasWin) {
|
|
||||||
scoreCell.classList.add("score-win");
|
|
||||||
totalCell.classList.add("score-win");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scoreCell.textContent = "";
|
|
||||||
totalCell.textContent = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_appendRow() {
|
|
||||||
let newRow = document.createElement("tr");
|
|
||||||
newRow.classList.add("score-entry");
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
newRow.appendChild(document.createElement("td"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scoreTableTarget.appendChild(newRow);
|
|
||||||
return newRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore1() {
|
|
||||||
this._addScore(this.score1InputTarget, this.score2InputTarget,
|
|
||||||
this._scorecard.addPlayer1Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer1Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore2() {
|
|
||||||
this._addScore(this.score2InputTarget, this.score1InputTarget,
|
|
||||||
this._scorecard.addPlayer2Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer2Score.bind(this._scorecard));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) {
|
|
||||||
let score = parseInt(inputElem.value);
|
|
||||||
if (isNaN(score)) {
|
|
||||||
score = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScoreFn(score);
|
|
||||||
this._undoStack.push(queueUndoFn);
|
|
||||||
|
|
||||||
inputElem.value = "";
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
|
|
||||||
focusToInputElem.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
score1KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore1.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
score2KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore2.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleKeyDown(e, addScoreFn) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addScoreFn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
undoLast() {
|
|
||||||
if (this._undoStack.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm("Really undo last move?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(this._undoStack.pop())();
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAll() {
|
|
||||||
if (!confirm("Really reset?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._scorecard.reset();
|
|
||||||
storeDao.clear();
|
|
||||||
this._undoStack = [];
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { FinskaScorecardController } from "./controllers.js"
|
|
||||||
|
|
||||||
window.Stimulus = Application.start();
|
|
||||||
|
|
||||||
window.Stimulus.register('finska-scorecard', FinskaScorecardController);
|
|
||||||
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
export class ScoreEntry {
|
|
||||||
constructor(score, total, foul, wasOverflow, wasWin) {
|
|
||||||
this.score = score;
|
|
||||||
this.total = total;
|
|
||||||
this.fouls = foul;
|
|
||||||
this.wasOverflow = wasOverflow;
|
|
||||||
this.wasWin = wasWin;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Scorecard {
|
|
||||||
|
|
||||||
constructor(rules) {
|
|
||||||
this.rules = rules;
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._player1Scores = [];
|
|
||||||
this._player2Scores = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer1Score(newScore) {
|
|
||||||
this._addScore(this._player1Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer1Score() {
|
|
||||||
this._player1Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer2Score(newScore) {
|
|
||||||
this._addScore(this._player2Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer2Score() {
|
|
||||||
this._player2Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(playerScores, newScore) {
|
|
||||||
let lastEntry;
|
|
||||||
if (playerScores.length === 0) {
|
|
||||||
lastEntry = new ScoreEntry(0, 0, 0, false, false);
|
|
||||||
} else {
|
|
||||||
lastEntry = playerScores[playerScores.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
let newEntry = this._newEntryFromPrevious(newScore, lastEntry);
|
|
||||||
|
|
||||||
playerScores.push(newEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
length() {
|
|
||||||
return Math.max(this._player1Scores.length, this._player2Scores.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
pairs() {
|
|
||||||
let pairs = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.length(); i++) {
|
|
||||||
pairs.push({
|
|
||||||
p1: (i < this._player1Scores.length ? this._player1Scores[i] : null),
|
|
||||||
p2: (i < this._player2Scores.length ? this._player2Scores[i] : null),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEntryFromPrevious(score, previousScoreEntry) {
|
|
||||||
if (previousScoreEntry === null) {
|
|
||||||
return new ScoreEntry(score, score, (score === 0 ? 1 : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
let wasOverflow = false, wasWin = false;
|
|
||||||
let newTotal = previousScoreEntry.total + score;
|
|
||||||
|
|
||||||
if (newTotal === this.rules.maxScore) {
|
|
||||||
wasWin = true;
|
|
||||||
} else if (newTotal > this.rules.maxScore) {
|
|
||||||
newTotal = this.rules.overflowScoreTo;
|
|
||||||
wasOverflow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newFouls = previousScoreEntry.foul;
|
|
||||||
if (score === 0) {
|
|
||||||
newFouls++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ScoreEntry(score, newTotal, newFouls, wasOverflow, wasWin);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJson() {
|
|
||||||
return {
|
|
||||||
"version": 1,
|
|
||||||
"rules": this.rules,
|
|
||||||
"p1": { "scores": this._player1Scores.map(p => p.score) },
|
|
||||||
"p2": { "scores": this._player2Scores.map(p => p.score) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJson(o) {
|
|
||||||
let scorecard = new Scorecard(o.rules);
|
|
||||||
o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x));
|
|
||||||
o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x));
|
|
||||||
return scorecard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StoreDAO {
|
|
||||||
|
|
||||||
constructor(localStorage) {
|
|
||||||
this._localStorage = localStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(scoreCard) {
|
|
||||||
this._localStorage.setItem('finska-scorecard', JSON.stringify(scoreCard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrCreate(rules) {
|
|
||||||
try {
|
|
||||||
console.log("Loading scorecard");
|
|
||||||
let scoreCardJson = this._localStorage.getItem('finska-scorecard');
|
|
||||||
if (scoreCardJson !== null) {
|
|
||||||
return Scorecard.fromJson(JSON.parse(scoreCardJson));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`Could not restore game: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Scorecard(rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this._localStorage.removeItem('finska-scorecard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DummyStoreDAO {
|
|
||||||
save(scoreCard) { }
|
|
||||||
loadOrCreate(rules) {
|
|
||||||
return new Scorecard(rules);
|
|
||||||
}
|
|
||||||
clear() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoreDAO() {
|
|
||||||
if (!!window.localStorage) {
|
|
||||||
return new StoreDAO(window.localStorage);
|
|
||||||
} else {
|
|
||||||
return new DummyStoreDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
tfoot input {
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-foul {
|
|
||||||
color: #861D13;
|
|
||||||
background-color: #F8DCD6;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-overflow {
|
|
||||||
color: #5B4200;
|
|
||||||
background-color: #FCEFD9;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-win {
|
|
||||||
color: #394D00;
|
|
||||||
background-color: #DEFC85;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Hex Color Converter - Tools</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
<script src="./script.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body class="container">
|
|
||||||
<header>
|
|
||||||
<hgroup>
|
|
||||||
<h1>Hex Color Converter</h1>
|
|
||||||
<p>Convert hex color values to normalized RGB components</p>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<button id="addHexBtn">Add Hex</button>
|
|
||||||
|
|
||||||
<table id="colorTable" class="hidden">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Preview</th>
|
|
||||||
<th>Hex</th>
|
|
||||||
<th>Normalized (R, G, B, A)</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="colorTableBody">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<button id="showHiddenBtn" class="hidden secondary">Show Hidden Rows</button>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
async function addHexFromClipboard() {
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
const hex = text.trim();
|
|
||||||
|
|
||||||
// Parse hex color
|
|
||||||
const color = parseHexColor(hex);
|
|
||||||
if (!color) {
|
|
||||||
alert('Invalid hex color in clipboard. Expected format: #RRGGBB or #RRGGBBAA');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add row to table
|
|
||||||
addColorRow(hex, color);
|
|
||||||
|
|
||||||
// Show table if hidden
|
|
||||||
document.getElementById('colorTable').classList.remove('hidden');
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to read from clipboard: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('addHexBtn').addEventListener('click', addHexFromClipboard);
|
|
||||||
|
|
||||||
document.getElementById('showHiddenBtn').addEventListener('click', () => {
|
|
||||||
const rows = document.querySelectorAll('#colorTableBody tr.hidden');
|
|
||||||
rows.forEach(row => row.classList.remove('hidden'));
|
|
||||||
updateShowHiddenButton();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'p' || e.key === 'P') {
|
|
||||||
addHexFromClipboard();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseHexColor(hex) {
|
|
||||||
// Remove leading hash if present
|
|
||||||
const cleanHex = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
||||||
|
|
||||||
// Validate hex string (6 or 8 characters)
|
|
||||||
if (!/^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(cleanHex)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse components
|
|
||||||
const r = parseInt(cleanHex.slice(0, 2), 16) / 255;
|
|
||||||
const g = parseInt(cleanHex.slice(2, 4), 16) / 255;
|
|
||||||
const b = parseInt(cleanHex.slice(4, 6), 16) / 255;
|
|
||||||
const a = cleanHex.length === 8 ? parseInt(cleanHex.slice(6, 8), 16) / 255 : 1.0;
|
|
||||||
|
|
||||||
return { r, g, b, a, hex: '#' + cleanHex };
|
|
||||||
}
|
|
||||||
|
|
||||||
function addColorRow(originalHex, color) {
|
|
||||||
const tbody = document.getElementById('colorTableBody');
|
|
||||||
const row = tbody.insertRow();
|
|
||||||
|
|
||||||
// Preview cell
|
|
||||||
const previewCell = row.insertCell();
|
|
||||||
const preview = document.createElement('div');
|
|
||||||
preview.style.width = '40px';
|
|
||||||
preview.style.height = '40px';
|
|
||||||
preview.style.backgroundColor = `rgba(${color.r * 255}, ${color.g * 255}, ${color.b * 255}, ${color.a})`;
|
|
||||||
preview.style.border = '1px solid #ccc';
|
|
||||||
preview.style.borderRadius = '4px';
|
|
||||||
previewCell.appendChild(preview);
|
|
||||||
|
|
||||||
// Hex cell
|
|
||||||
const hexCell = row.insertCell();
|
|
||||||
hexCell.textContent = color.hex;
|
|
||||||
|
|
||||||
// Normalized components cell
|
|
||||||
const normalizedCell = row.insertCell();
|
|
||||||
const normalizedText = `${color.r.toFixed(1)}, ${color.g.toFixed(1)}, ${color.b.toFixed(1)}, ${color.a.toFixed(1)}`;
|
|
||||||
normalizedCell.textContent = normalizedText;
|
|
||||||
|
|
||||||
// Action cell with copy and hide buttons
|
|
||||||
const actionCell = row.insertCell();
|
|
||||||
|
|
||||||
const copyBtn = document.createElement('button');
|
|
||||||
copyBtn.textContent = 'Copy';
|
|
||||||
copyBtn.className = 'secondary';
|
|
||||||
copyBtn.addEventListener('click', () => {
|
|
||||||
navigator.clipboard.writeText(normalizedText).then(() => {
|
|
||||||
const originalText = copyBtn.textContent;
|
|
||||||
copyBtn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => {
|
|
||||||
copyBtn.textContent = originalText;
|
|
||||||
}, 1500);
|
|
||||||
}).catch(err => {
|
|
||||||
alert('Failed to copy: ' + err.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
actionCell.appendChild(copyBtn);
|
|
||||||
|
|
||||||
const hideBtn = document.createElement('button');
|
|
||||||
hideBtn.textContent = 'Hide';
|
|
||||||
hideBtn.className = 'secondary';
|
|
||||||
hideBtn.addEventListener('click', () => {
|
|
||||||
row.classList.add('hidden');
|
|
||||||
updateShowHiddenButton();
|
|
||||||
});
|
|
||||||
actionCell.appendChild(hideBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateShowHiddenButton() {
|
|
||||||
const hiddenRows = document.querySelectorAll('#colorTableBody tr.hidden');
|
|
||||||
const showHiddenBtn = document.getElementById('showHiddenBtn');
|
|
||||||
|
|
||||||
if (hiddenRows.length > 0) {
|
|
||||||
showHiddenBtn.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
showHiddenBtn.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorTable {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorTable button {
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#colorTable td {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#showHiddenBtn {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
@ -24,13 +24,6 @@
|
||||||
<li><a href="/gradient-bands/">Gradient Bands</a></li>
|
<li><a href="/gradient-bands/">Gradient Bands</a></li>
|
||||||
<li><a href="/2lcc/">Two-letter Country Codes</a></li>
|
<li><a href="/2lcc/">Two-letter Country Codes</a></li>
|
||||||
<li><a href="/timestamps/">Timestamp Converter</a></li>
|
<li><a href="/timestamps/">Timestamp Converter</a></li>
|
||||||
<li><a href="/scorecard-2p/">Generic Scorecard - 2 Players</a></li>
|
|
||||||
<li><a href="/scorecard-4p/">Generic Scorecard - 4 Players</a></li>
|
|
||||||
<li><a href="/mahjong/">Mahjong Scorecard</a></li>
|
|
||||||
<li><a href="/finska/">Finska Scorecard</a></li>
|
|
||||||
<li><a href="/mental-arithmatic/">Mental Arithmatic Game</a></li>
|
|
||||||
<li><a href="/neon-snake/">Neon Snake</a>: vibe-coded by Google Gemini</li>
|
|
||||||
<li><a href="/hex-color/">Hex Color Converter</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -32,92 +32,66 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>m</code></td>
|
<td><code>m</code></td>
|
||||||
<td>Mahjong</td>
|
<td>Mahjong</td>
|
||||||
<td>20</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>ps</code></td>
|
<td><code>ps</code></td>
|
||||||
<td>Pung of simples</td>
|
<td>Pung of simples</td>
|
||||||
<td>4</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>pt</code></td>
|
<td><code>pt</code></td>
|
||||||
<td>Pung of terminals</td>
|
<td>Pung of terminals</td>
|
||||||
<td>8</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>ph</code></td>
|
<td><code>ph</code></td>
|
||||||
<td>Pung of honours</td>
|
<td>Pung of honours</td>
|
||||||
<td>8</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xps</code></td>
|
<td><code>xps</code></td>
|
||||||
<td>Exposed pung of simples</td>
|
<td>Exposed pung of simples</td>
|
||||||
<td>2</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xpt</code></td>
|
<td><code>xpt</code></td>
|
||||||
<td>Exposed pung of terminals</td>
|
<td>Exposed pung of terminals</td>
|
||||||
<td>4</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xph</code></td>
|
<td><code>xph</code></td>
|
||||||
<td>Exposed pung of honours</td>
|
<td>Exposed pung of honours</td>
|
||||||
<td>4</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>ks</code></td>
|
<td><code>ks</code></td>
|
||||||
<td>Kong of simples</td>
|
<td>Kong of simples</td>
|
||||||
<td>16</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>kt</code></td>
|
<td><code>kt</code></td>
|
||||||
<td>Kong of terminals</td>
|
<td>Kong of terminals</td>
|
||||||
<td>32</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>kh</code></td>
|
<td><code>kh</code></td>
|
||||||
<td>Kong of honours</td>
|
<td>Kong of honours</td>
|
||||||
<td>32</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xks</code></td>
|
<td><code>xks</code></td>
|
||||||
<td>Exposed kong of simples</td>
|
<td>Exposed kong of simples</td>
|
||||||
<td>8</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xkt</code></td>
|
<td><code>xkt</code></td>
|
||||||
<td>Exposed kong of terminals</td>
|
<td>Exposed kong of terminals</td>
|
||||||
<td>16</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>xkh</code></td>
|
<td><code>xkh</code></td>
|
||||||
<td>Exposed kong of honours</td>
|
<td>Exposed kong of honours</td>
|
||||||
<td>16</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>pd</code></td>
|
<td><code>pd</code></td>
|
||||||
<td>Pair of dragons</td>
|
<td>Pair of dragons</td>
|
||||||
<td>2</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>pw</code></td>
|
<td><code>pw</code></td>
|
||||||
<td>Pair of winds (either player or prevailing)</td>
|
<td>Pair of winds (either player or prevailing)</td>
|
||||||
<td>2</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>b</code></td>
|
<td><code>b</code></td>
|
||||||
<td>Bonus (flower or season)</td>
|
<td>Bonus (flower or season)</td>
|
||||||
<td>2</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>p</code></td>
|
|
||||||
<td>Point (used for adjusting scores)</td>
|
|
||||||
<td>1</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code>d</code></td>
|
|
||||||
<td>Penalty (used for adjusting scores)</td>
|
|
||||||
<td>-1</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -78,21 +78,15 @@
|
||||||
|
|
||||||
<form data-endround-target="form"></form>
|
<form data-endround-target="form"></form>
|
||||||
|
|
||||||
|
<p data-endround-target="wind"></p>
|
||||||
|
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<div>
|
<div></div>
|
||||||
<div data-endround-target="wind"></div>
|
|
||||||
<div data-endround-target="prevailing"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<button data-action="click->endround#goBack">Cancel</button>
|
<button data-action="click->endround#goBack">Cancel</button>
|
||||||
<button data-action="click->endround#nextRound">Next Round</button>
|
<button data-action="click->endround#nextRound">Next Round</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btns">
|
|
||||||
<div></div>
|
|
||||||
<a href="guide.html" target="_blank">Notation Guide</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="./main.js" type="module"></script>
|
<script src="./main.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/di
|
||||||
import { GameState, getGameState, setGameState, calculateScore } from "./models.js";
|
import { GameState, getGameState, setGameState, calculateScore } from "./models.js";
|
||||||
|
|
||||||
function emitGameStateEvent(details) {
|
function emitGameStateEvent(details) {
|
||||||
let ev = new CustomEvent("gamestatechanged", { detail: details });
|
// window.setTimeout(() => {
|
||||||
window.dispatchEvent(ev);
|
let ev = new CustomEvent("gamestatechanged", { detail: details });
|
||||||
|
window.dispatchEvent(ev);
|
||||||
|
// }, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
window.Stimulus = Application.start();
|
window.Stimulus = Application.start();
|
||||||
|
|
||||||
Stimulus.register("scorecard", class extends Controller {
|
Stimulus.register("scorecard", class extends Controller {
|
||||||
|
|
@ -15,6 +18,8 @@ Stimulus.register("scorecard", class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
// this._rebuildTable();
|
||||||
|
// this.element.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGameState(ev) {
|
handleGameState(ev) {
|
||||||
|
|
@ -70,8 +75,8 @@ Stimulus.register("scorecard", class extends Controller {
|
||||||
let td = document.createElement("td");
|
let td = document.createElement("td");
|
||||||
td.textContent = c.s;
|
td.textContent = c.s;
|
||||||
if (c.w) {
|
if (c.w) {
|
||||||
td.textContent += " 🀄";
|
|
||||||
td.classList.add("winner");
|
td.classList.add("winner");
|
||||||
|
td.textContent += " 🏆";
|
||||||
}
|
}
|
||||||
tr.append(td);
|
tr.append(td);
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +94,7 @@ Stimulus.register("scorecard", class extends Controller {
|
||||||
|
|
||||||
this.element.querySelector("table.scorecard").replaceWith(tbl);
|
this.element.querySelector("table.scorecard").replaceWith(tbl);
|
||||||
|
|
||||||
this.prevailingTarget.textContent = `Prevailing: ${gameState.prevailing}`;
|
this.prevailingTarget.textContent = `Prevailing: ${gameState.prevaling}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -97,6 +102,7 @@ Stimulus.register("newgame", class extends Controller {
|
||||||
static targets = ["playerName"];
|
static targets = ["playerName"];
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
this.element.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGameState(ev) {
|
handleGameState(ev) {
|
||||||
|
|
@ -127,9 +133,10 @@ Stimulus.register("newgame", class extends Controller {
|
||||||
});
|
});
|
||||||
|
|
||||||
Stimulus.register("endround", class extends Controller {
|
Stimulus.register("endround", class extends Controller {
|
||||||
static targets = ["form", "input", "wind", "prevailing"];
|
static targets = ["form", "input", "wind"];
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
// this.element.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGameState(ev) {
|
handleGameState(ev) {
|
||||||
|
|
@ -141,7 +148,6 @@ Stimulus.register("endround", class extends Controller {
|
||||||
break;
|
break;
|
||||||
case "endround":
|
case "endround":
|
||||||
this._prepForms();
|
this._prepForms();
|
||||||
this._updatePreview();
|
|
||||||
this.element.classList.remove("hidden");
|
this.element.classList.remove("hidden");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -149,10 +155,7 @@ Stimulus.register("endround", class extends Controller {
|
||||||
|
|
||||||
updatePreview(ev) {
|
updatePreview(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this._updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updatePreview() {
|
|
||||||
let scoreExprs = this._readScoreExprs();
|
let scoreExprs = this._readScoreExprs();
|
||||||
let nextRound = getGameState().determineNextRound(scoreExprs);
|
let nextRound = getGameState().determineNextRound(scoreExprs);
|
||||||
|
|
||||||
|
|
@ -160,23 +163,15 @@ Stimulus.register("endround", class extends Controller {
|
||||||
let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`);
|
let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`);
|
||||||
previewElem.textContent = nextRound.roundScores[i].score;
|
previewElem.textContent = nextRound.roundScores[i].score;
|
||||||
if (parseInt(i) === nextRound.roundWinner) {
|
if (parseInt(i) === nextRound.roundWinner) {
|
||||||
previewElem.classList.add("winner");
|
previewElem.textContent += " 🏆";
|
||||||
previewElem.textContent += " 🀄";
|
|
||||||
} else {
|
|
||||||
previewElem.classList.remove("winner");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextRound.windsBump) {
|
if (nextRound.windsBump) {
|
||||||
this.windTarget.textContent = ` will be East next round`;
|
this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will be East next round`;
|
||||||
} else {
|
} else {
|
||||||
this.windTarget.textContent = ` will remain East next round`;
|
this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will remain East next round`;
|
||||||
}
|
}
|
||||||
let nextWindNameElem = document.createElement("strong");
|
|
||||||
nextWindNameElem.textContent = getGameState().players[nextRound.nextRoundEast].name;
|
|
||||||
this.windTarget.prepend(nextWindNameElem);
|
|
||||||
|
|
||||||
this.prevailingTarget.textContent = `Next prevailing: ${nextRound.nextPrevailing}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(ev) {
|
goBack(ev) {
|
||||||
|
|
@ -230,12 +225,3 @@ Stimulus.register("endround", class extends Controller {
|
||||||
return scoreExprs;
|
return scoreExprs;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
let game = getGameState();
|
|
||||||
if (game !== null) {
|
|
||||||
emitGameStateEvent({mode: "startgame"});
|
|
||||||
} else {
|
|
||||||
emitGameStateEvent({mode: "newgame"});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -7,24 +7,22 @@ const windDistributions = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const scoreTokens = {
|
const scoreTokens = {
|
||||||
'm': 20,
|
'm': 1,
|
||||||
'ps': 4,
|
'ps': 1,
|
||||||
'pt': 8,
|
'pt': 1,
|
||||||
'ph': 8,
|
'ph': 1,
|
||||||
'xps': 2,
|
'xps': 1,
|
||||||
'xpt': 4,
|
'xpt': 1,
|
||||||
'xph': 4,
|
'xph': 1,
|
||||||
'ks': 16,
|
'ks': 1,
|
||||||
'kt': 32,
|
'kt': 1,
|
||||||
'kh': 32,
|
'kh': 1,
|
||||||
'xks': 8,
|
'xks': 1,
|
||||||
'xkt': 16,
|
'xkt': 1,
|
||||||
'xkh': 16,
|
'xkh': 1,
|
||||||
'pd': 2,
|
'pd': 1,
|
||||||
'pw': 2,
|
'pw': 1,
|
||||||
'b': 2,
|
'b': 1,
|
||||||
'p': 1,
|
|
||||||
'd': -1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTokens(str) {
|
function parseTokens(str) {
|
||||||
|
|
@ -69,7 +67,7 @@ export class GameState {
|
||||||
this.players = players;
|
this.players = players;
|
||||||
this.rounds = rounds;
|
this.rounds = rounds;
|
||||||
this.bumpWind = 0;
|
this.bumpWind = 0;
|
||||||
this.prevailing = "E";
|
this.prevaling = "E";
|
||||||
}
|
}
|
||||||
|
|
||||||
static newGame(playerNames) {
|
static newGame(playerNames) {
|
||||||
|
|
@ -80,21 +78,7 @@ export class GameState {
|
||||||
}
|
}
|
||||||
|
|
||||||
let rounds = [];
|
let rounds = [];
|
||||||
let gs = new GameState(players, rounds);
|
return new GameState(players, rounds);
|
||||||
gs.save();
|
|
||||||
return gs;
|
|
||||||
}
|
|
||||||
|
|
||||||
static load() {
|
|
||||||
let json = localStorage.getItem('mahjong-scorecard');
|
|
||||||
if (!json) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let o = JSON.parse(json);
|
|
||||||
let gs = new GameState(o.p, o.r);
|
|
||||||
gs.bumpWind = o.b;
|
|
||||||
gs.prevailing = o.w;
|
|
||||||
return gs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
determineNextRound(playerScoreExprs) {
|
determineNextRound(playerScoreExprs) {
|
||||||
|
|
@ -108,7 +92,7 @@ export class GameState {
|
||||||
}
|
}
|
||||||
|
|
||||||
let windsBump = roundWinner !== currentEast;
|
let windsBump = roundWinner !== currentEast;
|
||||||
let nextPrevailing = this.prevailing;
|
let nextPrevailing = this.prevaling;
|
||||||
if (windsBump) {
|
if (windsBump) {
|
||||||
nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0];
|
nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0];
|
||||||
}
|
}
|
||||||
|
|
@ -149,24 +133,14 @@ export class GameState {
|
||||||
this.players[pi].wind = windDistribution[i];
|
this.players[pi].wind = windDistribution[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.prevailing = nr.nextPrevailing;
|
this.prevaling = nr.nextPrevailing;
|
||||||
if (nr.windsBump) {
|
if (nr.windsBump) {
|
||||||
this.bumpWind++;
|
this.bumpWind++;
|
||||||
}
|
}
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
|
||||||
localStorage.setItem('mahjong-scorecard', JSON.stringify({
|
|
||||||
p: this.players,
|
|
||||||
r: this.rounds,
|
|
||||||
b: this.bumpWind,
|
|
||||||
w: this.prevailing,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let gameState = GameState.load();
|
let gameState = new GameState();
|
||||||
|
|
||||||
export function getGameState() {
|
export function getGameState() {
|
||||||
return gameState;
|
return gameState;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.container {
|
|
||||||
margin-block-end: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -22,41 +18,4 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-block-end: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.btns {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btns :nth-child(1) {
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btns :nth-child(2) {
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.winner {
|
|
||||||
color: #9B2318;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: #E2E2E2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
th {
|
|
||||||
background: #474747;
|
|
||||||
}
|
|
||||||
|
|
||||||
.winner {
|
|
||||||
color: #F06048;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
|
||||||
<title>Mental Arithmatic - Tools</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
<script src="./scripts/main.js" type="module"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="welcome-card" class="container welcome-card" data-controller="welcome"
|
|
||||||
data-action="endGame@window->welcome#gameEnded">
|
|
||||||
<header>
|
|
||||||
<hgroup>
|
|
||||||
<h1>Fear of All Sums</h1>
|
|
||||||
<p>A simple mental arithmetic game</p>
|
|
||||||
</hgroup>
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<article class="game-variant">
|
|
||||||
<h3>Simple Sums</h3>
|
|
||||||
<p>A two number sum with each term up to two digits.</p>
|
|
||||||
<footer>
|
|
||||||
<button data-action="click->welcome#startGame">Start Game</button>
|
|
||||||
<div class="high-scores" data-welcome-target="simpleHighScores"></div>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
<div id="countdown-card" class="container game-card hidden" data-controller="countdown"
|
|
||||||
data-action="startCountdown@window->countdown#start">
|
|
||||||
<header>
|
|
||||||
<span>2:00</span>
|
|
||||||
<span>0</span>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div class="countdown-timer" data-countdown-target="countdown">3</div>
|
|
||||||
<input class="fake" type="number" data-countdown-target="fakeinput" value="0">
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<div id="game-card" class="container game-card hidden" data-controller="game"
|
|
||||||
data-action="startGame@window->game#start">
|
|
||||||
<header>
|
|
||||||
<span data-game-target="clock">1:00</span>
|
|
||||||
<span data-game-target="score">0</span>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div class="question">
|
|
||||||
<div data-game-target="problem"></div>
|
|
||||||
<div class="answer" data-game-target="indicators">
|
|
||||||
<span class="indicator right">✔︎</span>
|
|
||||||
<span class="indicator wrong">❌</span>
|
|
||||||
<input type="number" data-game-target="answer" value="0" data-action="keydown.enter->game#submitAnswer">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
|
|
||||||
export class CountdownController extends Controller {
|
|
||||||
static targets = ["countdown", "fakeinput"];
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this.element.classList.remove('hidden');
|
|
||||||
|
|
||||||
this._countdown = 3;
|
|
||||||
this.countdownTarget.innerText = this._countdown;
|
|
||||||
|
|
||||||
this._tickInterval = window.setInterval(this._tick.bind(this), 1000);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.fakeinputTarget.focus();
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
_tick() {
|
|
||||||
this._countdown -= 1;
|
|
||||||
|
|
||||||
if (this._countdown === 0) {
|
|
||||||
this.countdownTarget.innerText = "GO!";
|
|
||||||
} else if (this._countdown < 0) {
|
|
||||||
window.clearInterval(this._tickInterval);
|
|
||||||
this._startActualGame();
|
|
||||||
} else {
|
|
||||||
this.countdownTarget.innerText = this._countdown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startActualGame() {
|
|
||||||
this.element.classList.add('hidden');
|
|
||||||
window.dispatchEvent(new CustomEvent("startGame"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
|
|
||||||
export class GameController extends Controller {
|
|
||||||
static targets = [
|
|
||||||
"clock",
|
|
||||||
"score",
|
|
||||||
"problem",
|
|
||||||
"indicators",
|
|
||||||
"answer",
|
|
||||||
];
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this.startGame();
|
|
||||||
this.element.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
submitAnswer(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
this._checkAnswer();
|
|
||||||
}
|
|
||||||
|
|
||||||
startGame() {
|
|
||||||
this._clock = 120;
|
|
||||||
this._score = 0;
|
|
||||||
|
|
||||||
this._generateProblem();
|
|
||||||
this._startClock();
|
|
||||||
|
|
||||||
this._updateClock();
|
|
||||||
}
|
|
||||||
|
|
||||||
_startClock() {
|
|
||||||
this._clockInterval = window.setInterval(() => {
|
|
||||||
if (this._clock <= 0) {
|
|
||||||
this._gameOver();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._clock -= 1;
|
|
||||||
this._updateClock();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_gameOver() {
|
|
||||||
window.clearInterval(this._clockInterval);
|
|
||||||
|
|
||||||
this.element.classList.add('hidden');
|
|
||||||
window.dispatchEvent(new CustomEvent("endGame"));
|
|
||||||
}
|
|
||||||
|
|
||||||
_generateProblem() {
|
|
||||||
const num1 = Math.floor(Math.random() * 99) + 1;
|
|
||||||
const num2 = Math.floor(Math.random() * 99) + 1;
|
|
||||||
|
|
||||||
this._problem = [num1, num2];
|
|
||||||
this._answer = num1 + num2;
|
|
||||||
|
|
||||||
this.indicatorsTarget.classList.remove('right', 'wrong');
|
|
||||||
this.problemTarget.innerHTML = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < this._problem.length; i++) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
|
|
||||||
if (i === this._problem.length - 1) {
|
|
||||||
div.textContent = '+ ' + this._problem[i];
|
|
||||||
} else {
|
|
||||||
div.textContent = this._problem[i];
|
|
||||||
}
|
|
||||||
this.problemTarget.appendChild(div);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.answerTarget.value = "";
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.answerTarget.focus();
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
_checkAnswer() {
|
|
||||||
let isRight = parseInt(this.answerTarget.value) === this._answer;
|
|
||||||
let delay = 500;
|
|
||||||
|
|
||||||
if (isRight) {
|
|
||||||
this._score += 1;
|
|
||||||
this.indicatorsTarget.classList.add('right');
|
|
||||||
} else {
|
|
||||||
this.indicatorsTarget.classList.add('wrong');
|
|
||||||
this.answerTarget.value = this._answer;
|
|
||||||
delay = 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scoreTarget.textContent = this._score;
|
|
||||||
|
|
||||||
window.setTimeout(() => { this._generateProblem(); }, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateClock() {
|
|
||||||
let m = Math.floor(this._clock / 60);
|
|
||||||
let s = this._clock % 60;
|
|
||||||
this.clockTarget.textContent = `${m}:${s < 10 ? '0' : ''}${s}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { WelcomeController } from "./welcome.js"
|
|
||||||
import { CountdownController } from "./countdown.js"
|
|
||||||
import { GameController } from "./game.js"
|
|
||||||
|
|
||||||
window.Stimulus = Application.start();
|
|
||||||
|
|
||||||
window.Stimulus.register('welcome', WelcomeController);
|
|
||||||
window.Stimulus.register('countdown', CountdownController);
|
|
||||||
window.Stimulus.register('game', GameController);
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
|
|
||||||
export class WelcomeController extends Controller {
|
|
||||||
static targets = ["simpleHighScores"];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this._buildHighScores();
|
|
||||||
}
|
|
||||||
|
|
||||||
startGame(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
this.element.classList.add('hidden');
|
|
||||||
window.dispatchEvent(new CustomEvent("startCountdown"));
|
|
||||||
}
|
|
||||||
|
|
||||||
gameEnded(ev) {
|
|
||||||
this.element.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
_buildHighScores() {
|
|
||||||
let scores = {
|
|
||||||
last: 12,
|
|
||||||
high: 10,
|
|
||||||
streak: 3
|
|
||||||
};
|
|
||||||
|
|
||||||
let newEls = [
|
|
||||||
`<span>🏆 ${scores.last}</span>`,
|
|
||||||
`<span>🔥 ${scores.streak}</span>`,
|
|
||||||
`<span>↩️ ${scores.high}</span>`
|
|
||||||
];
|
|
||||||
this.simpleHighScoresTarget.innerHTML = newEls.join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
--pico-form-element-disabled-opacity: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-card .game-variant footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-card .game-variant .high-scores {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-timer {
|
|
||||||
font-size: 3em;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.fake {
|
|
||||||
position: fixed;
|
|
||||||
top: -600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card header,
|
|
||||||
.game-card footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
font-size: 2em;
|
|
||||||
margin-block: 12px;
|
|
||||||
margin-inline: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card main {
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-shrink: 1;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding-block-start: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .question {
|
|
||||||
margin: auto;
|
|
||||||
width: 60vw;
|
|
||||||
|
|
||||||
text-align: right;
|
|
||||||
font-size: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer {
|
|
||||||
border-block: 2px solid black;
|
|
||||||
padding-block: 2px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.right {
|
|
||||||
border-color: #4EB31B;
|
|
||||||
color: #4EB31B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.wrong {
|
|
||||||
border-color: #EE402E;
|
|
||||||
color: #EE402E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer .indicator {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.right .indicator.right {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.wrong .indicator.wrong {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .question input {
|
|
||||||
border-inline: none;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
text-align: right;
|
|
||||||
font-size: 1em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .question input:focus {
|
|
||||||
border-none: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.right input {
|
|
||||||
color: green;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card .answer.wrong input {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<title>Neon Snake</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--neon-green: #39ff14;
|
|
||||||
--neon-red: #ff0055;
|
|
||||||
--neon-blue: #00ffff;
|
|
||||||
--bg-color: #050510;
|
|
||||||
--grid-line: rgba(0, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--neon-green);
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CRT Scanline Effect Overlay */
|
|
||||||
.scanlines {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(255,255,255,0),
|
|
||||||
rgba(255,255,255,0) 50%,
|
|
||||||
rgba(0,0,0,0.1) 50%,
|
|
||||||
rgba(0,0,0,0.1)
|
|
||||||
);
|
|
||||||
background-size: 100% 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container for the game */
|
|
||||||
#game-container {
|
|
||||||
position: relative;
|
|
||||||
box-shadow: 0 0 20px rgba(57, 255, 20, 0.2);
|
|
||||||
border: 2px solid var(--neon-green);
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
|
||||||
background-size: 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ui-layer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
text-shadow: 0 0 5px var(--neon-green);
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#start-screen, #game-over-screen {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
text-align: center;
|
|
||||||
background: rgba(5, 5, 16, 0.9);
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid var(--neon-green);
|
|
||||||
box-shadow: 0 0 15px var(--neon-green);
|
|
||||||
pointer-events: auto;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--neon-blue);
|
|
||||||
text-shadow: 0 0 10px var(--neon-blue);
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--neon-green);
|
|
||||||
border: 2px solid var(--neon-green);
|
|
||||||
padding: 10px 30px;
|
|
||||||
font-family: 'VT323', monospace;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-transform: uppercase;
|
|
||||||
box-shadow: 0 0 10px var(--neon-green);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: var(--neon-green);
|
|
||||||
color: black;
|
|
||||||
box-shadow: 0 0 20px var(--neon-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile specific controls hint */
|
|
||||||
.controls-hint {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #888;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="scanlines"></div>
|
|
||||||
|
|
||||||
<div id="game-container">
|
|
||||||
<canvas id="gameCanvas" width="400" height="400"></canvas>
|
|
||||||
|
|
||||||
<div id="ui-layer">
|
|
||||||
<div class="header">
|
|
||||||
<span id="score-display">SCORE: 000</span>
|
|
||||||
<span id="highscore-display">HI: 000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="start-screen">
|
|
||||||
<h1>NEON SNAKE</h1>
|
|
||||||
<p>USE ARROW KEYS OR SWIPE</p>
|
|
||||||
<button onclick="startGame()">INITIALIZE</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="game-over-screen" class="hidden">
|
|
||||||
<h1 style="color: var(--neon-red); text-shadow: 0 0 10px var(--neon-red);">SYSTEM FAILURE</h1>
|
|
||||||
<p>FINAL SCORE: <span id="final-score">0</span></p>
|
|
||||||
<button onclick="startGame()">REBOOT</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls-hint">Desktop: Arrows/WASD/IJKL | Mobile: Swipe</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const canvas = document.getElementById('gameCanvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const scoreEl = document.getElementById('score-display');
|
|
||||||
const highScoreEl = document.getElementById('highscore-display');
|
|
||||||
const startScreen = document.getElementById('start-screen');
|
|
||||||
const gameOverScreen = document.getElementById('game-over-screen');
|
|
||||||
const finalScoreEl = document.getElementById('final-score');
|
|
||||||
|
|
||||||
// Game Configuration
|
|
||||||
let tileCount = 20; // 20x20 grid
|
|
||||||
let tileSize = canvas.width / tileCount - 2; // -2 for gap
|
|
||||||
let gameSpeed = 100; // ms per frame
|
|
||||||
|
|
||||||
// Game State
|
|
||||||
let score = 0;
|
|
||||||
let highScore = localStorage.getItem('snake-highscore') || 0;
|
|
||||||
let snake = [];
|
|
||||||
let food = { x: 15, y: 15 };
|
|
||||||
let dx = 0;
|
|
||||||
let dy = 0;
|
|
||||||
let gameInterval;
|
|
||||||
let isGameRunning = false;
|
|
||||||
let lastInputTime = 0;
|
|
||||||
|
|
||||||
// Initialize display
|
|
||||||
highScoreEl.innerText = `HI: ${String(highScore).padStart(3, '0')}`;
|
|
||||||
resizeCanvas();
|
|
||||||
|
|
||||||
// Responsive Canvas
|
|
||||||
window.addEventListener('resize', resizeCanvas);
|
|
||||||
|
|
||||||
function resizeCanvas() {
|
|
||||||
// Constrain canvas to window size for mobile, max 600px
|
|
||||||
let size = Math.min(window.innerWidth - 40, window.innerHeight - 100, 600);
|
|
||||||
// Ensure size is a multiple of tileCount for clean rendering
|
|
||||||
size = Math.floor(size / tileCount) * tileCount;
|
|
||||||
|
|
||||||
canvas.width = size;
|
|
||||||
canvas.height = size;
|
|
||||||
tileSize = size / tileCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startGame() {
|
|
||||||
startScreen.classList.add('hidden');
|
|
||||||
gameOverScreen.classList.add('hidden');
|
|
||||||
nextTouch = null;
|
|
||||||
|
|
||||||
// Reset State
|
|
||||||
snake = [{x: 10, y: 10}, {x: 9, y: 10}, {x: 8, y: 10}];
|
|
||||||
score = 0;
|
|
||||||
dx = 1; // Start moving right
|
|
||||||
dy = 0;
|
|
||||||
updateScore(0);
|
|
||||||
placeFood();
|
|
||||||
|
|
||||||
if (gameInterval) clearInterval(gameInterval);
|
|
||||||
isGameRunning = true;
|
|
||||||
gameInterval = setInterval(gameLoop, gameSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gameLoop() {
|
|
||||||
if (!isGameRunning) return;
|
|
||||||
|
|
||||||
// Move Snake
|
|
||||||
const head = { x: snake[0].x + dx, y: snake[0].y + dy };
|
|
||||||
|
|
||||||
// Wall Collision Check (Wrap around logic can be used, but let's do hard walls for retro feel)
|
|
||||||
if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount) {
|
|
||||||
gameOver();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self Collision Check
|
|
||||||
for (let i = 0; i < snake.length; i++) {
|
|
||||||
if (head.x === snake[i].x && head.y === snake[i].y) {
|
|
||||||
gameOver();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
snake.unshift(head);
|
|
||||||
|
|
||||||
// Eat Food
|
|
||||||
if (head.x === food.x && head.y === food.y) {
|
|
||||||
score += 10;
|
|
||||||
updateScore(score);
|
|
||||||
// Speed up slightly every 50 points
|
|
||||||
if (score % 50 === 0 && gameSpeed > 50) {
|
|
||||||
clearInterval(gameInterval);
|
|
||||||
gameSpeed -= 5;
|
|
||||||
gameInterval = setInterval(gameLoop, gameSpeed);
|
|
||||||
}
|
|
||||||
placeFood();
|
|
||||||
} else {
|
|
||||||
snake.pop(); // Remove tail
|
|
||||||
}
|
|
||||||
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
// Clear screen
|
|
||||||
ctx.fillStyle = '#000000'; // Pure black for high contrast
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Draw Food (Glitchy Red Square)
|
|
||||||
ctx.shadowBlur = 15;
|
|
||||||
ctx.shadowColor = "#ff0055";
|
|
||||||
ctx.fillStyle = "#ff0055";
|
|
||||||
ctx.fillRect(food.x * tileSize + 1, food.y * tileSize + 1, tileSize - 2, tileSize - 2);
|
|
||||||
|
|
||||||
// Draw Snake
|
|
||||||
snake.forEach((part, index) => {
|
|
||||||
// Head is brighter, tail fades slightly
|
|
||||||
if (index === 0) {
|
|
||||||
ctx.fillStyle = "#ffffff"; // White hot head
|
|
||||||
ctx.shadowBlur = 20;
|
|
||||||
ctx.shadowColor = "#39ff14";
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = "#39ff14";
|
|
||||||
ctx.shadowBlur = 10 - Math.min(index, 8); // Fading glow
|
|
||||||
ctx.shadowColor = "#39ff14";
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillRect(part.x * tileSize + 1, part.y * tileSize + 1, tileSize - 2, tileSize - 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset shadow for next frame performance
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeFood() {
|
|
||||||
// Random position not on snake
|
|
||||||
let valid = false;
|
|
||||||
while (!valid) {
|
|
||||||
food.x = Math.floor(Math.random() * tileCount);
|
|
||||||
food.y = Math.floor(Math.random() * tileCount);
|
|
||||||
|
|
||||||
valid = true;
|
|
||||||
for (let part of snake) {
|
|
||||||
if (part.x === food.x && part.y === food.y) {
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScore(val) {
|
|
||||||
scoreEl.innerText = `SCORE: ${String(val).padStart(3, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gameOver() {
|
|
||||||
isGameRunning = false;
|
|
||||||
clearInterval(gameInterval);
|
|
||||||
nextTouch = startGame;
|
|
||||||
|
|
||||||
if (score > highScore) {
|
|
||||||
highScore = score;
|
|
||||||
localStorage.setItem('snake-highscore', highScore);
|
|
||||||
highScoreEl.innerText = `HI: ${String(highScore).padStart(3, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalScoreEl.innerText = score;
|
|
||||||
gameOverScreen.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input Handling
|
|
||||||
document.addEventListener('keydown', changeDirection);
|
|
||||||
let nextTouch = startGame;
|
|
||||||
|
|
||||||
function changeDirection(event) {
|
|
||||||
if (nextTouch) {
|
|
||||||
let nt = nextTouch;
|
|
||||||
nextTouch = null;
|
|
||||||
nt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEFT_KEY = 37;
|
|
||||||
const RIGHT_KEY = 39;
|
|
||||||
const UP_KEY = 38;
|
|
||||||
const DOWN_KEY = 40;
|
|
||||||
const W_KEY = 87;
|
|
||||||
const A_KEY = 65;
|
|
||||||
const S_KEY = 83;
|
|
||||||
const D_KEY = 68;
|
|
||||||
const I_KEY = 73;
|
|
||||||
const J_KEY = 74;
|
|
||||||
const K_KEY = 75;
|
|
||||||
const L_KEY = 76;
|
|
||||||
|
|
||||||
// Prevent reversing direction immediately
|
|
||||||
const goingUp = dy === -1;
|
|
||||||
const goingDown = dy === 1;
|
|
||||||
const goingRight = dx === 1;
|
|
||||||
const goingLeft = dx === -1;
|
|
||||||
|
|
||||||
const keyPressed = event.keyCode;
|
|
||||||
|
|
||||||
// Prevent default scrolling
|
|
||||||
if([37, 38, 39, 40].indexOf(keyPressed) > -1) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input buffering (simple check to prevent double turn in one frame)
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastInputTime < 50) return;
|
|
||||||
lastInputTime = now;
|
|
||||||
|
|
||||||
if ((keyPressed === LEFT_KEY || keyPressed === A_KEY || keyPressed == J_KEY) && !goingRight) {
|
|
||||||
dx = -1; dy = 0;
|
|
||||||
}
|
|
||||||
if ((keyPressed === UP_KEY || keyPressed === W_KEY || keyPressed == I_KEY) && !goingDown) {
|
|
||||||
dx = 0; dy = -1;
|
|
||||||
}
|
|
||||||
if ((keyPressed === RIGHT_KEY || keyPressed === D_KEY || keyPressed == L_KEY) && !goingLeft) {
|
|
||||||
dx = 1; dy = 0;
|
|
||||||
}
|
|
||||||
if ((keyPressed === DOWN_KEY || keyPressed === S_KEY || keyPressed == K_KEY) && !goingUp) {
|
|
||||||
dx = 0; dy = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch Handling (Swipe)
|
|
||||||
let touchStartX = 0;
|
|
||||||
let touchStartY = 0;
|
|
||||||
|
|
||||||
document.addEventListener('touchstart', function(event) {
|
|
||||||
touchStartX = event.changedTouches[0].screenX;
|
|
||||||
touchStartY = event.changedTouches[0].screenY;
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
document.addEventListener('touchmove', function(event) {
|
|
||||||
// Prevent scrolling while playing
|
|
||||||
if(isGameRunning) event.preventDefault();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
document.addEventListener('touchend', function(event) {
|
|
||||||
let touchEndX = event.changedTouches[0].screenX;
|
|
||||||
let touchEndY = event.changedTouches[0].screenY;
|
|
||||||
handleSwipe(touchStartX, touchStartY, touchEndX, touchEndY);
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
function handleSwipe(startX, startY, endX, endY) {
|
|
||||||
let diffX = endX - startX;
|
|
||||||
let diffY = endY - startY;
|
|
||||||
|
|
||||||
// Threshold to count as a swipe
|
|
||||||
if (Math.abs(diffX) < 30 && Math.abs(diffY) < 30) return;
|
|
||||||
|
|
||||||
const goingUp = dy === -1;
|
|
||||||
const goingDown = dy === 1;
|
|
||||||
const goingRight = dx === 1;
|
|
||||||
const goingLeft = dx === -1;
|
|
||||||
|
|
||||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
|
||||||
// Horizontal Swipe
|
|
||||||
if (diffX > 0 && !goingLeft) { dx = 1; dy = 0; }
|
|
||||||
if (diffX < 0 && !goingRight) { dx = -1; dy = 0; }
|
|
||||||
} else {
|
|
||||||
// Vertical Swipe
|
|
||||||
if (diffY > 0 && !goingUp) { dx = 0; dy = 1; }
|
|
||||||
if (diffY < 0 && !goingDown) { dx = 0; dy = -1; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial draw
|
|
||||||
draw();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Score Card - Tools</title>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
|
|
||||||
>
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
<script src="./scripts/main.js" type="module"></script>
|
|
||||||
</head>
|
|
||||||
<body class="container">
|
|
||||||
<header>
|
|
||||||
<hgroup>
|
|
||||||
<h1>Scorecard</h1>
|
|
||||||
<p>2 Players</p>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<div data-controller="finska-scorecard">
|
|
||||||
<table class="score-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">Player A</td>
|
|
||||||
<td colspan="2">Player B</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody data-finska-scorecard-target="scoreTable">
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score1Input" data-action="keydown->finska-scorecard#score1KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score2Input" data-action="keydown->finska-scorecard#score2KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="action-bar">
|
|
||||||
<button data-action="finska-scorecard#undoLast">
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
<button data-action="finska-scorecard#resetAll">
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { Scorecard, getStoreDAO } from "./models.js";
|
|
||||||
|
|
||||||
const storeDao = getStoreDAO();
|
|
||||||
|
|
||||||
export class FinskaScorecardController extends Controller {
|
|
||||||
static targets = ["score1Input", "score2Input", "scoreTable"];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this._undoStack = [];
|
|
||||||
this._scorecard = storeDao.loadOrCreate();
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTable() {
|
|
||||||
let tableBody = this.scoreTableTarget;
|
|
||||||
let tableRows = tableBody.querySelectorAll("tr");
|
|
||||||
let pairs = this._scorecard.pairs();
|
|
||||||
|
|
||||||
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
|
||||||
let tableRow;
|
|
||||||
if (pairIndex >= tableRows.length) {
|
|
||||||
tableRow = this._appendRow();
|
|
||||||
} else {
|
|
||||||
tableRow = tableRows[pairIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
this._updateRow(tableRow, pairs[pairIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any extra rows
|
|
||||||
for (let i = pairs.length; i < tableRows.length; i++) {
|
|
||||||
tableBody.removeChild(tableRows[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(this._scorecard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateRow(tableRow, pair) {
|
|
||||||
let tds = tableRow.querySelectorAll("td");
|
|
||||||
|
|
||||||
this._updateCell(pair.p1, tds[0], tds[1]);
|
|
||||||
this._updateCell(pair.p2, tds[2], tds[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateCell(score, scoreCell, totalCell) {
|
|
||||||
scoreCell.classList.value = "";
|
|
||||||
totalCell.classList.value = "";
|
|
||||||
|
|
||||||
if (score != null) {
|
|
||||||
scoreCell.textContent = score.score;
|
|
||||||
totalCell.textContent = score.total;
|
|
||||||
} else {
|
|
||||||
scoreCell.textContent = "";
|
|
||||||
totalCell.textContent = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_appendRow() {
|
|
||||||
let newRow = document.createElement("tr");
|
|
||||||
newRow.classList.add("score-entry");
|
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
newRow.appendChild(document.createElement("td"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scoreTableTarget.appendChild(newRow);
|
|
||||||
return newRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore1() {
|
|
||||||
this._addScore(this.score1InputTarget, this.score2InputTarget,
|
|
||||||
this._scorecard.addPlayer1Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer1Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore2() {
|
|
||||||
this._addScore(this.score2InputTarget, this.score1InputTarget,
|
|
||||||
this._scorecard.addPlayer2Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer2Score.bind(this._scorecard));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) {
|
|
||||||
let score = parseInt(inputElem.value);
|
|
||||||
if (isNaN(score)) {
|
|
||||||
score = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScoreFn(score);
|
|
||||||
this._undoStack.push(queueUndoFn);
|
|
||||||
|
|
||||||
inputElem.value = "";
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
|
|
||||||
focusToInputElem.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
score1KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore1.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
score2KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore2.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleKeyDown(e, addScoreFn) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addScoreFn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
undoLast() {
|
|
||||||
if (this._undoStack.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(this._undoStack.pop())();
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAll() {
|
|
||||||
if (!confirm("Really reset?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._scorecard.reset();
|
|
||||||
storeDao.clear();
|
|
||||||
this._undoStack = [];
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { FinskaScorecardController } from "./controllers.js"
|
|
||||||
|
|
||||||
window.Stimulus = Application.start();
|
|
||||||
|
|
||||||
window.Stimulus.register('finska-scorecard', FinskaScorecardController);
|
|
||||||
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
export class ScoreEntry {
|
|
||||||
constructor(score, total) {
|
|
||||||
this.score = score;
|
|
||||||
this.total = total;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Scorecard {
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._player1Scores = [];
|
|
||||||
this._player2Scores = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer1Score(newScore) {
|
|
||||||
this._addScore(this._player1Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer1Score() {
|
|
||||||
this._player1Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer2Score(newScore) {
|
|
||||||
this._addScore(this._player2Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer2Score() {
|
|
||||||
this._player2Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(playerScores, newScore) {
|
|
||||||
let lastEntry;
|
|
||||||
if (playerScores.length === 0) {
|
|
||||||
lastEntry = new ScoreEntry(0, 0, 0, false, false);
|
|
||||||
} else {
|
|
||||||
lastEntry = playerScores[playerScores.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
let newEntry = this._newEntryFromPrevious(newScore, lastEntry);
|
|
||||||
|
|
||||||
playerScores.push(newEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
length() {
|
|
||||||
return Math.max(this._player1Scores.length, this._player2Scores.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
pairs() {
|
|
||||||
let pairs = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.length(); i++) {
|
|
||||||
pairs.push({
|
|
||||||
p1: (i < this._player1Scores.length ? this._player1Scores[i] : null),
|
|
||||||
p2: (i < this._player2Scores.length ? this._player2Scores[i] : null),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEntryFromPrevious(score, previousScoreEntry) {
|
|
||||||
if (previousScoreEntry === null) {
|
|
||||||
return new ScoreEntry(score, score, (score === 0 ? 1 : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTotal = previousScoreEntry.total + score;
|
|
||||||
return new ScoreEntry(score, newTotal);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJson() {
|
|
||||||
return {
|
|
||||||
"version": 1,
|
|
||||||
"p1": { "scores": this._player1Scores.map(p => p.score) },
|
|
||||||
"p2": { "scores": this._player2Scores.map(p => p.score) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJson(o) {
|
|
||||||
let scorecard = new Scorecard();
|
|
||||||
o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x));
|
|
||||||
o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x));
|
|
||||||
return scorecard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StoreDAO {
|
|
||||||
|
|
||||||
constructor(localStorage) {
|
|
||||||
this._localStorage = localStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(scoreCard) {
|
|
||||||
this._localStorage.setItem('generic-scorecard-2p', JSON.stringify(scoreCard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrCreate() {
|
|
||||||
try {
|
|
||||||
console.log("Loading scorecard");
|
|
||||||
let scoreCardJson = this._localStorage.getItem('generic-scorecard-2p');
|
|
||||||
if (scoreCardJson !== null) {
|
|
||||||
return Scorecard.fromJson(JSON.parse(scoreCardJson));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`Could not restore game: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Scorecard();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this._localStorage.removeItem('generic-scorecard-2p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DummyStoreDAO {
|
|
||||||
save(scoreCard) { }
|
|
||||||
loadOrCreate() {
|
|
||||||
return new Scorecard();
|
|
||||||
}
|
|
||||||
clear() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoreDAO() {
|
|
||||||
if (!!window.localStorage) {
|
|
||||||
return new StoreDAO(window.localStorage);
|
|
||||||
} else {
|
|
||||||
return new DummyStoreDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
tfoot input {
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-foul {
|
|
||||||
color: #861D13;
|
|
||||||
background-color: #F8DCD6;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-overflow {
|
|
||||||
color: #5B4200;
|
|
||||||
background-color: #FCEFD9;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-win {
|
|
||||||
color: #394D00;
|
|
||||||
background-color: #DEFC85;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Score Card - Tools</title>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
|
|
||||||
>
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
<script src="./scripts/main.js" type="module"></script>
|
|
||||||
</head>
|
|
||||||
<body class="container">
|
|
||||||
<header>
|
|
||||||
<hgroup>
|
|
||||||
<h1>Scorecard</h1>
|
|
||||||
<p>4 Players</p>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<div data-controller="finska-scorecard">
|
|
||||||
<table class="score-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">Player A</td>
|
|
||||||
<td colspan="2">Player B</td>
|
|
||||||
<td colspan="2">Player C</td>
|
|
||||||
<td colspan="2">Player D</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody data-finska-scorecard-target="scoreTable">
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score1Input" data-action="keydown->finska-scorecard#score1KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score2Input" data-action="keydown->finska-scorecard#score2KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score3Input" data-action="keydown->finska-scorecard#score3KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<form>
|
|
||||||
<input class="score-input" type="number"
|
|
||||||
data-finska-scorecard-target="score4Input" data-action="keydown->finska-scorecard#score4KeyDown">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="action-bar">
|
|
||||||
<button data-action="finska-scorecard#undoLast">
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
<button data-action="finska-scorecard#resetAll">
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { Scorecard, getStoreDAO } from "./models.js";
|
|
||||||
|
|
||||||
const storeDao = getStoreDAO();
|
|
||||||
|
|
||||||
export class FinskaScorecardController extends Controller {
|
|
||||||
static targets = ["score1Input", "score2Input", "score3Input", "score4Input", "scoreTable"];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this._undoStack = [];
|
|
||||||
this._scorecard = storeDao.loadOrCreate();
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTable() {
|
|
||||||
let tableBody = this.scoreTableTarget;
|
|
||||||
let tableRows = tableBody.querySelectorAll("tr");
|
|
||||||
let pairs = this._scorecard.pairs();
|
|
||||||
|
|
||||||
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
|
||||||
let tableRow;
|
|
||||||
if (pairIndex >= tableRows.length) {
|
|
||||||
tableRow = this._appendRow();
|
|
||||||
} else {
|
|
||||||
tableRow = tableRows[pairIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
this._updateRow(tableRow, pairs[pairIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any extra rows
|
|
||||||
for (let i = pairs.length; i < tableRows.length; i++) {
|
|
||||||
tableBody.removeChild(tableRows[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(this._scorecard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateRow(tableRow, pair) {
|
|
||||||
let tds = tableRow.querySelectorAll("td");
|
|
||||||
|
|
||||||
this._updateCell(pair.p1, tds[0], tds[1]);
|
|
||||||
this._updateCell(pair.p2, tds[2], tds[3]);
|
|
||||||
this._updateCell(pair.p3, tds[4], tds[5]);
|
|
||||||
this._updateCell(pair.p4, tds[6], tds[7]);
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateCell(score, scoreCell, totalCell) {
|
|
||||||
scoreCell.classList.value = "";
|
|
||||||
totalCell.classList.value = "";
|
|
||||||
|
|
||||||
if (score != null) {
|
|
||||||
scoreCell.textContent = score.score;
|
|
||||||
totalCell.textContent = score.total;
|
|
||||||
} else {
|
|
||||||
scoreCell.textContent = "";
|
|
||||||
totalCell.textContent = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_appendRow() {
|
|
||||||
let newRow = document.createElement("tr");
|
|
||||||
newRow.classList.add("score-entry");
|
|
||||||
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
newRow.appendChild(document.createElement("td"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scoreTableTarget.appendChild(newRow);
|
|
||||||
return newRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore1() {
|
|
||||||
this._addScore(this.score1InputTarget, this.score2InputTarget,
|
|
||||||
this._scorecard.addPlayer1Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer1Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore2() {
|
|
||||||
this._addScore(this.score2InputTarget, this.score3InputTarget,
|
|
||||||
this._scorecard.addPlayer2Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer2Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore3() {
|
|
||||||
this._addScore(this.score3InputTarget, this.score4InputTarget,
|
|
||||||
this._scorecard.addPlayer3Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer3Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
addScore4() {
|
|
||||||
this._addScore(this.score4InputTarget, this.score1InputTarget,
|
|
||||||
this._scorecard.addPlayer4Score.bind(this._scorecard),
|
|
||||||
this._scorecard.removeLastPlayer4Score.bind(this._scorecard));
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) {
|
|
||||||
let score = parseInt(inputElem.value);
|
|
||||||
if (isNaN(score)) {
|
|
||||||
score = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
addScoreFn(score);
|
|
||||||
this._undoStack.push(queueUndoFn);
|
|
||||||
|
|
||||||
inputElem.value = "";
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
|
|
||||||
focusToInputElem.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
score1KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore1.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
score2KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore2.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
score3KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore3.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
score4KeyDown(e) {
|
|
||||||
this._handleKeyDown(e, this.addScore4.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleKeyDown(e, addScoreFn) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addScoreFn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
undoLast() {
|
|
||||||
if (this._undoStack.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(this._undoStack.pop())();
|
|
||||||
|
|
||||||
storeDao.save(this._scorecard);
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAll() {
|
|
||||||
if (!confirm("Really reset?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._scorecard.reset();
|
|
||||||
storeDao.clear();
|
|
||||||
this._undoStack = [];
|
|
||||||
this.updateTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
||||||
import { FinskaScorecardController } from "./controllers.js"
|
|
||||||
|
|
||||||
window.Stimulus = Application.start();
|
|
||||||
|
|
||||||
window.Stimulus.register('finska-scorecard', FinskaScorecardController);
|
|
||||||
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
export class ScoreEntry {
|
|
||||||
constructor(score, total) {
|
|
||||||
this.score = score;
|
|
||||||
this.total = total;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Scorecard {
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._player1Scores = [];
|
|
||||||
this._player2Scores = [];
|
|
||||||
this._player3Scores = [];
|
|
||||||
this._player4Scores = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer1Score(newScore) {
|
|
||||||
this._addScore(this._player1Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer1Score() {
|
|
||||||
this._player1Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer2Score(newScore) {
|
|
||||||
this._addScore(this._player2Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer2Score() {
|
|
||||||
this._player2Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer3Score(newScore) {
|
|
||||||
this._addScore(this._player3Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer3Score() {
|
|
||||||
this._player3Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
addPlayer4Score(newScore) {
|
|
||||||
this._addScore(this._player4Scores, newScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLastPlayer4Score() {
|
|
||||||
this._player4Scores.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
_addScore(playerScores, newScore) {
|
|
||||||
let lastEntry;
|
|
||||||
if (playerScores.length === 0) {
|
|
||||||
lastEntry = new ScoreEntry(0, 0, 0, false, false);
|
|
||||||
} else {
|
|
||||||
lastEntry = playerScores[playerScores.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
let newEntry = this._newEntryFromPrevious(newScore, lastEntry);
|
|
||||||
|
|
||||||
playerScores.push(newEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
length() {
|
|
||||||
return Math.max(this._player1Scores.length, this._player2Scores.length,
|
|
||||||
this._player3Scores.length, this._player4Scores.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
pairs() {
|
|
||||||
let pairs = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.length(); i++) {
|
|
||||||
pairs.push({
|
|
||||||
p1: (i < this._player1Scores.length ? this._player1Scores[i] : null),
|
|
||||||
p2: (i < this._player2Scores.length ? this._player2Scores[i] : null),
|
|
||||||
p3: (i < this._player3Scores.length ? this._player3Scores[i] : null),
|
|
||||||
p4: (i < this._player4Scores.length ? this._player4Scores[i] : null),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEntryFromPrevious(score, previousScoreEntry) {
|
|
||||||
if (previousScoreEntry === null) {
|
|
||||||
return new ScoreEntry(score, score, (score === 0 ? 1 : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTotal = previousScoreEntry.total + score;
|
|
||||||
return new ScoreEntry(score, newTotal);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJson() {
|
|
||||||
return {
|
|
||||||
"version": 1,
|
|
||||||
"p1": { "scores": this._player1Scores.map(p => p.score) },
|
|
||||||
"p2": { "scores": this._player2Scores.map(p => p.score) },
|
|
||||||
"p3": { "scores": this._player3Scores.map(p => p.score) },
|
|
||||||
"p4": { "scores": this._player4Scores.map(p => p.score) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJson(o) {
|
|
||||||
let scorecard = new Scorecard();
|
|
||||||
o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x));
|
|
||||||
o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x));
|
|
||||||
if (o["p3"]) o["p3"]["scores"].forEach(x => scorecard.addPlayer3Score(x));
|
|
||||||
if (o["p4"]) o["p4"]["scores"].forEach(x => scorecard.addPlayer4Score(x));
|
|
||||||
return scorecard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StoreDAO {
|
|
||||||
|
|
||||||
constructor(localStorage) {
|
|
||||||
this._localStorage = localStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(scoreCard) {
|
|
||||||
this._localStorage.setItem('generic-scorecard-4p', JSON.stringify(scoreCard.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrCreate() {
|
|
||||||
try {
|
|
||||||
console.log("Loading scorecard");
|
|
||||||
let scoreCardJson = this._localStorage.getItem('generic-scorecard-4p');
|
|
||||||
if (scoreCardJson !== null) {
|
|
||||||
return Scorecard.fromJson(JSON.parse(scoreCardJson));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`Could not restore game: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Scorecard();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this._localStorage.removeItem('generic-scorecard-4p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DummyStoreDAO {
|
|
||||||
save(scoreCard) { }
|
|
||||||
loadOrCreate() {
|
|
||||||
return new Scorecard();
|
|
||||||
}
|
|
||||||
clear() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoreDAO() {
|
|
||||||
if (!!window.localStorage) {
|
|
||||||
return new StoreDAO(window.localStorage);
|
|
||||||
} else {
|
|
||||||
return new DummyStoreDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
tfoot input {
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-foul {
|
|
||||||
color: #861D13;
|
|
||||||
background-color: #F8DCD6;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-overflow {
|
|
||||||
color: #5B4200;
|
|
||||||
background-color: #FCEFD9;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-win {
|
|
||||||
color: #394D00;
|
|
||||||
background-color: #DEFC85;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue