Compare commits

..

No commits in common. "main" and "feature/mahjong-scorecard" have entirely different histories.

48 changed files with 43 additions and 2981 deletions

View file

@ -23,5 +23,4 @@ jobs:
make
- name: Deploy
run: |
npm install netlify-cli --save-dev
npx netlify deploy --dir target --prod
netlify deploy --dir target --prod

2
.gitignore vendored
View file

@ -1,3 +1 @@
target/
.vscode/
.idea/

View file

@ -1,11 +0,0 @@
# Webtools
This is a static site for a set of tools available on the web. The root of the site is located in "site", with index.html holding a list of available tools. Within each subdirectory of "site" is a separate tool. Each tool is self-contained with it's own index.html, CSS and JavaScript. Multipes of these files can be added if necessary. Note that each HTML has a standard header, with a typical title convension and importing "pico.min.css".
Some tools can include a WASM file for more complex tasks. The WASM targets are built from Go files, with each cmd located as a sub directory in the "cmds" directory. Each one is built as a standalone WASM target.
To build:
- Run `build.wasm` to build the WASM targets. Additional targets will need to be added to this Make target
- Run `build` to build both the WASM and prepare the site. The site will be available in a "target" directory.
- Run `dev` to build the site and run a dev HTML server to test the site on port 8000. The dev server can be killed by sending a SIGINT signal.

View file

@ -14,7 +14,6 @@ build.wasm:
GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks
GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate
GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps
GOOS=js GOARCH=wasm go build -o target/wasm/android-icons.wasm ./cmds/android-icons
cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/.
.Phony: build.site

View file

@ -1,144 +0,0 @@
//go:build js
package main
import (
"archive/zip"
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"strings"
"syscall/js"
"golang.org/x/image/draw"
)
type density struct {
dir string
size int
}
var densities = []density{
{"mipmap-mdpi", 48},
{"mipmap-hdpi", 72},
{"mipmap-xhdpi", 96},
{"mipmap-xxhdpi", 144},
{"mipmap-xxxhdpi", 192},
}
func main() {
js.Global().Set("prepareZip", js.FuncOf(prepareZip))
<-make(chan struct{})
}
func prepareZip(this js.Value, args []js.Value) interface{} {
// args[0] is an array of {name: string, data: string (base64 PNG data)}
files := args[0]
length := files.Length()
if length == 0 {
js.Global().Call("alert", "No files selected.")
return nil
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < length; i++ {
file := files.Index(i)
name := file.Get("name").String()
dataURL := file.Get("data").String()
// Strip data URL prefix (data:image/png;base64,)
commaIdx := strings.Index(dataURL, ",")
if commaIdx < 0 {
setStatus(fmt.Sprintf("Invalid data for %s", name))
return nil
}
b64Data := dataURL[commaIdx+1:]
imgData, err := base64.StdEncoding.DecodeString(b64Data)
if err != nil {
setStatus(fmt.Sprintf("Failed to decode %s: %v", name, err))
return nil
}
src, err := png.Decode(bytes.NewReader(imgData))
if err != nil {
setStatus(fmt.Sprintf("Failed to decode PNG %s: %v", name, err))
return nil
}
// Strip extension from name for the base name
baseName := name
if strings.HasSuffix(strings.ToLower(baseName), ".png") {
baseName = baseName[:len(baseName)-4]
}
for _, d := range densities {
resized := resizeImage(src, d.size)
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, resized); err != nil {
setStatus(fmt.Sprintf("Failed to encode %s/%s.png: %v", d.dir, baseName, err))
return nil
}
path := fmt.Sprintf("%s/%s.png", d.dir, baseName)
w, err := zw.Create(path)
if err != nil {
setStatus(fmt.Sprintf("Failed to create zip entry %s: %v", path, err))
return nil
}
if _, err := w.Write(pngBuf.Bytes()); err != nil {
setStatus(fmt.Sprintf("Failed to write zip entry %s: %v", path, err))
return nil
}
}
}
if err := zw.Close(); err != nil {
setStatus(fmt.Sprintf("Failed to finalize zip: %v", err))
return nil
}
// Convert zip bytes to a JS Uint8Array and trigger download
zipBytes := buf.Bytes()
jsArray := js.Global().Get("Uint8Array").New(len(zipBytes))
js.CopyBytesToJS(jsArray, zipBytes)
// Create Blob and download
blobParts := js.Global().Get("Array").New()
blobParts.Call("push", jsArray)
blobOpts := js.Global().Get("Object").New()
blobOpts.Set("type", "application/zip")
blob := js.Global().Get("Blob").New(blobParts, blobOpts)
url := js.Global().Get("URL").Call("createObjectURL", blob)
anchor := js.Global().Get("document").Call("createElement", "a")
anchor.Set("href", url)
anchor.Set("download", "android-icons.zip")
js.Global().Get("document").Get("body").Call("appendChild", anchor)
anchor.Call("click")
anchor.Call("remove")
js.Global().Get("URL").Call("revokeObjectURL", url)
setStatus(fmt.Sprintf("Done! Prepared icons for %d image(s).", length))
return nil
}
func resizeImage(src image.Image, size int) image.Image {
dst := image.NewNRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}
func setStatus(msg string) {
querySelector("#status").Set("innerText", msg)
}
func querySelector(query string) js.Value {
return js.Global().Get("document").Call("querySelector", query)
}

7
go.mod
View file

@ -1,8 +1,5 @@
module github.com/lmika/webtools
go 1.25.0
go 1.24.3
require (
github.com/alecthomas/participle/v2 v2.1.4 // indirect
golang.org/x/image v0.37.0 // indirect
)
require github.com/alecthomas/participle/v2 v2.1.4 // indirect

2
go.sum
View file

@ -1,4 +1,2 @@
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=

View file

@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Android Icon Resizer - Tools</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body class="container">
<header>
<hgroup>
<h1>Android Icon Resizer</h1>
<p>Resize PNG images to Android mipmap density buckets. Everything runs in your browser.</p>
</hgroup>
</header>
<main>
<div>
<label for="file-input">Select PNG files</label>
<input type="file" id="file-input" accept="image/png" multiple>
</div>
<div id="preview-area"></div>
<div id="circle-preview">
<h3>Circle Crop Preview</h3>
<canvas id="circle-canvas" width="192" height="192"></canvas>
</div>
<div class="actions">
<button id="prepare-btn" disabled>Prepare</button>
</div>
<div id="status"></div>
</main>
<script src="main.js" type="module"></script>
</body>
</html>

View file

@ -1,136 +0,0 @@
import "/wasm/wasm_exec.js";
const go = new Go();
let wasmReady = false;
WebAssembly.instantiateStreaming(fetch("/wasm/android-icons.wasm"), go.importObject)
.then((result) => {
go.run(result.instance);
wasmReady = true;
});
const fileInput = document.getElementById("file-input");
const previewArea = document.getElementById("preview-area");
const prepareBtn = document.getElementById("prepare-btn");
const statusEl = document.getElementById("status");
const circlePreview = document.getElementById("circle-preview");
const circleCanvas = document.getElementById("circle-canvas");
let loadedFiles = [];
function renderCirclePreview() {
if (loadedFiles.length === 0) {
circlePreview.style.display = "none";
return;
}
const nameLower = (f) => f.name.toLowerCase();
let bgFile = loadedFiles.find((f) => /back/.test(nameLower(f)));
let fgFile = loadedFiles.find((f) => !/back/.test(nameLower(f)) && !/mono/.test(nameLower(f)));
// Fallbacks: if only one image or no "back" image, use the first file
if (!bgFile && !fgFile) {
bgFile = loadedFiles[0];
} else if (!bgFile) {
bgFile = fgFile;
fgFile = null;
}
const size = 192;
circleCanvas.width = size;
circleCanvas.height = size;
const ctx = circleCanvas.getContext("2d");
ctx.clearRect(0, 0, size, size);
// Clip to circle
ctx.save();
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
const drawLayer = (src) => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, size, size);
resolve();
};
img.onerror = resolve;
img.src = src;
});
};
drawLayer(bgFile.data).then(() => {
if (fgFile && fgFile !== bgFile) {
return drawLayer(fgFile.data);
}
}).then(() => {
ctx.restore();
// Draw border ring
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1.5, 0, Math.PI * 2);
ctx.closePath();
ctx.strokeStyle = "rgba(128, 128, 128, 0.5)";
ctx.lineWidth = 3;
ctx.stroke();
circlePreview.style.display = "block";
});
}
fileInput.addEventListener("change", () => {
const files = Array.from(fileInput.files);
loadedFiles = [];
previewArea.innerHTML = "";
circlePreview.style.display = "none";
if (files.length === 0) {
prepareBtn.disabled = true;
return;
}
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataURL = e.target.result;
loadedFiles.push({ name: file.name, data: dataURL });
const item = document.createElement("div");
item.className = "preview-item";
const img = document.createElement("img");
img.src = dataURL;
const label = document.createElement("span");
label.textContent = file.name;
item.appendChild(img);
item.appendChild(label);
previewArea.appendChild(item);
if (loadedFiles.length === files.length) {
prepareBtn.disabled = false;
renderCirclePreview();
}
};
reader.readAsDataURL(file);
});
});
prepareBtn.addEventListener("click", () => {
if (!wasmReady) {
statusEl.textContent = "WASM module is still loading, please wait...";
return;
}
if (loadedFiles.length === 0) {
return;
}
statusEl.textContent = "Preparing...";
// Pass file data to Go WASM
setTimeout(() => {
prepareZip(loadedFiles);
}, 50);
});

View file

@ -1,54 +0,0 @@
#preview-area {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 1rem 0;
}
.preview-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.preview-item img {
width: 96px;
height: 96px;
object-fit: contain;
border: 1px solid var(--pico-muted-border-color);
border-radius: 4px;
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
}
.preview-item span {
font-size: 0.8rem;
color: var(--pico-muted-color);
word-break: break-all;
max-width: 96px;
text-align: center;
}
.actions {
margin: 1rem 0;
}
#circle-preview {
display: none;
margin: 1rem 0;
}
#circle-preview h3 {
margin-bottom: 0.5rem;
}
#circle-canvas {
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
border-radius: 50%;
box-shadow: 0 0 0 3px var(--pico-muted-border-color);
}
#status {
margin-top: 0.5rem;
font-style: italic;
}

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gradient Image - Tools</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body class="container">
<header>
<hgroup>
<h1>Gradient Image</h1>
<p>Generate gradient images as downloadable PNGs</p>
</hgroup>
</header>
<main class="grid">
<section class="controls">
<label for="from-color">From Colour</label>
<input type="color" id="from-color" value="#ff0000">
<label for="to-color">To Colour</label>
<input type="color" id="to-color" value="#0000ff">
<label for="gradient-type">Gradient Type</label>
<select id="gradient-type">
<option value="linear">Linear</option>
<option value="radial">Radial</option>
</select>
<label for="rotation">Rotation: <span id="rotation-value">0</span>&deg;</label>
<input type="range" id="rotation" min="0" max="360" step="45" value="0">
<label for="image-size">Image Size</label>
<select id="image-size">
<option value="64">64 &times; 64</option>
<option value="128">128 &times; 128</option>
<option value="192">192 &times; 192</option>
<option value="256" selected>256 &times; 256</option>
<option value="512">512 &times; 512</option>
</select>
</section>
<section class="preview">
<canvas id="preview-canvas" width="256" height="256"></canvas>
<button id="download-btn">Download</button>
</section>
</main>
<script src="main.js" type="module"></script>
</body>
</html>

View file

@ -1,70 +0,0 @@
const PREVIEW_SIZE = 256;
const fromColorEl = document.getElementById("from-color");
const toColorEl = document.getElementById("to-color");
const gradientTypeEl = document.getElementById("gradient-type");
const rotationEl = document.getElementById("rotation");
const rotationValueEl = document.getElementById("rotation-value");
const imageSizeEl = document.getElementById("image-size");
const canvas = document.getElementById("preview-canvas");
const downloadBtn = document.getElementById("download-btn");
function drawGradient(ctx, size) {
const fromColor = fromColorEl.value;
const toColor = toColorEl.value;
const type = gradientTypeEl.value;
const rotation = parseInt(rotationEl.value);
const cx = size / 2;
const cy = size / 2;
let gradient;
if (type === "radial") {
const radius = size / 2;
gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
} else {
const angle = (rotation * Math.PI) / 180;
const len = size / 2;
const dx = Math.cos(angle) * len;
const dy = Math.sin(angle) * len;
gradient = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
}
gradient.addColorStop(0, fromColor);
gradient.addColorStop(1, toColor);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
}
function renderPreview() {
canvas.width = PREVIEW_SIZE;
canvas.height = PREVIEW_SIZE;
const ctx = canvas.getContext("2d");
drawGradient(ctx, PREVIEW_SIZE);
}
function download() {
const size = parseInt(imageSizeEl.value);
const offscreen = document.createElement("canvas");
offscreen.width = size;
offscreen.height = size;
const ctx = offscreen.getContext("2d");
drawGradient(ctx, size);
const link = document.createElement("a");
link.download = `gradient-${size}x${size}.png`;
link.href = offscreen.toDataURL("image/png");
link.click();
}
fromColorEl.addEventListener("input", renderPreview);
toColorEl.addEventListener("input", renderPreview);
gradientTypeEl.addEventListener("input", renderPreview);
rotationEl.addEventListener("input", () => {
rotationValueEl.textContent = rotationEl.value;
renderPreview();
});
downloadBtn.addEventListener("click", download);
renderPreview();

View file

@ -1,22 +0,0 @@
.controls {
display: flex;
flex-direction: column;
}
.preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#preview-canvas {
width: 256px;
height: 256px;
border: 1px solid var(--pico-muted-border-color);
border-radius: 4px;
}
#download-btn {
width: 100%;
}

View file

@ -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>

View file

@ -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');
}
}

View file

@ -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;
}

View file

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Inner Resize - Tools</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body class="container">
<header>
<hgroup>
<h1>Image Inner Resize</h1>
<p>Scale an image within its original dimensions, centered on a transparent background</p>
</hgroup>
</header>
<main class="grid">
<section class="controls">
<label for="file-input">Select PNG file</label>
<input type="file" id="file-input" accept="image/png">
<label for="scale">Scale: <span id="scale-value">100</span>%</label>
<input type="range" id="scale" min="0" max="100" step="5" value="100">
</section>
<section class="preview">
<canvas id="preview-canvas"></canvas>
<button id="download-btn" disabled>Download</button>
</section>
</main>
<script src="main.js" type="module"></script>
</body>
</html>

View file

@ -1,57 +0,0 @@
const fileInput = document.getElementById("file-input");
const scaleEl = document.getElementById("scale");
const scaleValueEl = document.getElementById("scale-value");
const canvas = document.getElementById("preview-canvas");
const downloadBtn = document.getElementById("download-btn");
let sourceImage = null;
function render() {
if (!sourceImage) return;
const w = sourceImage.naturalWidth;
const h = sourceImage.naturalHeight;
const scale = parseInt(scaleEl.value) / 100;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, w, h);
const sw = w * scale;
const sh = h * scale;
const sx = (w - sw) / 2;
const sy = (h - sh) / 2;
ctx.drawImage(sourceImage, sx, sy, sw, sh);
}
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
sourceImage = img;
downloadBtn.disabled = false;
render();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
scaleEl.addEventListener("input", () => {
scaleValueEl.textContent = scaleEl.value;
render();
});
downloadBtn.addEventListener("click", () => {
if (!sourceImage) return;
const link = document.createElement("a");
link.download = "resized.png";
link.href = canvas.toDataURL("image/png");
link.click();
});

View file

@ -1,23 +0,0 @@
.controls {
display: flex;
flex-direction: column;
}
.preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#preview-canvas {
max-width: 100%;
max-height: 512px;
border: 1px solid var(--pico-muted-border-color);
border-radius: 4px;
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
}
#download-btn {
width: 100%;
}

View file

@ -24,16 +24,6 @@
<li><a href="/gradient-bands/">Gradient Bands</a></li>
<li><a href="/2lcc/">Two-letter Country Codes</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>
<li><a href="/android-icons/">Android Icon Resizer</a></li>
<li><a href="/gradient-image/">Gradient Image</a></li>
<li><a href="/image-inner-resize/">Image Inner Resize</a></li>
</ul>
</main>
</body>

View file

@ -32,92 +32,66 @@
<tr>
<td><code>m</code></td>
<td>Mahjong</td>
<td>20</td>
</tr>
<tr>
<td><code>ps</code></td>
<td>Pung of simples</td>
<td>4</td>
</tr>
<tr>
<td><code>pt</code></td>
<td>Pung of terminals</td>
<td>8</td>
</tr>
<tr>
<td><code>ph</code></td>
<td>Pung of honours</td>
<td>8</td>
</tr>
<tr>
<td><code>xps</code></td>
<td>Exposed pung of simples</td>
<td>2</td>
</tr>
<tr>
<td><code>xpt</code></td>
<td>Exposed pung of terminals</td>
<td>4</td>
</tr>
<tr>
<td><code>xph</code></td>
<td>Exposed pung of honours</td>
<td>4</td>
</tr>
<tr>
<td><code>ks</code></td>
<td>Kong of simples</td>
<td>16</td>
</tr>
<tr>
<td><code>kt</code></td>
<td>Kong of terminals</td>
<td>32</td>
</tr>
<tr>
<td><code>kh</code></td>
<td>Kong of honours</td>
<td>32</td>
</tr>
<tr>
<td><code>xks</code></td>
<td>Exposed kong of simples</td>
<td>8</td>
</tr>
<tr>
<td><code>xkt</code></td>
<td>Exposed kong of terminals</td>
<td>16</td>
</tr>
<tr>
<td><code>xkh</code></td>
<td>Exposed kong of honours</td>
<td>16</td>
</tr>
<tr>
<td><code>pd</code></td>
<td>Pair of dragons</td>
<td>2</td>
</tr>
<tr>
<td><code>pw</code></td>
<td>Pair of winds (either player or prevailing)</td>
<td>2</td>
</tr>
<tr>
<td><code>b</code></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>
</tbody>
</table>

View file

@ -78,21 +78,15 @@
<form data-endround-target="form"></form>
<p data-endround-target="wind"></p>
<div class="btns">
<div>
<div data-endround-target="wind"></div>
<div data-endround-target="prevailing"></div>
</div>
<div></div>
<div>
<button data-action="click->endround#goBack">Cancel</button>
<button data-action="click->endround#nextRound">Next Round</button>
</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>
</body>

View file

@ -2,10 +2,13 @@ import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/di
import { GameState, getGameState, setGameState, calculateScore } from "./models.js";
function emitGameStateEvent(details) {
let ev = new CustomEvent("gamestatechanged", { detail: details });
window.dispatchEvent(ev);
// window.setTimeout(() => {
let ev = new CustomEvent("gamestatechanged", { detail: details });
window.dispatchEvent(ev);
// }, 1);
}
window.Stimulus = Application.start();
Stimulus.register("scorecard", class extends Controller {
@ -15,6 +18,8 @@ Stimulus.register("scorecard", class extends Controller {
}
connect() {
// this._rebuildTable();
// this.element.classList.remove("hidden");
}
handleGameState(ev) {
@ -70,8 +75,8 @@ Stimulus.register("scorecard", class extends Controller {
let td = document.createElement("td");
td.textContent = c.s;
if (c.w) {
td.textContent += " 🀄";
td.classList.add("winner");
td.textContent += " 🏆";
}
tr.append(td);
}
@ -89,7 +94,7 @@ Stimulus.register("scorecard", class extends Controller {
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"];
connect() {
this.element.classList.remove("hidden");
}
handleGameState(ev) {
@ -127,9 +133,10 @@ Stimulus.register("newgame", class extends Controller {
});
Stimulus.register("endround", class extends Controller {
static targets = ["form", "input", "wind", "prevailing"];
static targets = ["form", "input", "wind"];
connect() {
// this.element.classList.remove("hidden");
}
handleGameState(ev) {
@ -141,7 +148,6 @@ Stimulus.register("endround", class extends Controller {
break;
case "endround":
this._prepForms();
this._updatePreview();
this.element.classList.remove("hidden");
break;
}
@ -149,10 +155,7 @@ Stimulus.register("endround", class extends Controller {
updatePreview(ev) {
ev.preventDefault();
this._updatePreview();
}
_updatePreview() {
let scoreExprs = this._readScoreExprs();
let nextRound = getGameState().determineNextRound(scoreExprs);
@ -160,23 +163,15 @@ Stimulus.register("endround", class extends Controller {
let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`);
previewElem.textContent = nextRound.roundScores[i].score;
if (parseInt(i) === nextRound.roundWinner) {
previewElem.classList.add("winner");
previewElem.textContent += " 🀄";
} else {
previewElem.classList.remove("winner");
previewElem.textContent += " 🏆";
}
}
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 {
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) {
@ -230,12 +225,3 @@ Stimulus.register("endround", class extends Controller {
return scoreExprs;
}
});
document.addEventListener("DOMContentLoaded", () => {
let game = getGameState();
if (game !== null) {
emitGameStateEvent({mode: "startgame"});
} else {
emitGameStateEvent({mode: "newgame"});
}
})

View file

@ -7,24 +7,22 @@ const windDistributions = [
];
const scoreTokens = {
'm': 20,
'ps': 4,
'pt': 8,
'ph': 8,
'xps': 2,
'xpt': 4,
'xph': 4,
'ks': 16,
'kt': 32,
'kh': 32,
'xks': 8,
'xkt': 16,
'xkh': 16,
'pd': 2,
'pw': 2,
'b': 2,
'p': 1,
'd': -1,
'm': 1,
'ps': 1,
'pt': 1,
'ph': 1,
'xps': 1,
'xpt': 1,
'xph': 1,
'ks': 1,
'kt': 1,
'kh': 1,
'xks': 1,
'xkt': 1,
'xkh': 1,
'pd': 1,
'pw': 1,
'b': 1,
}
function parseTokens(str) {
@ -69,7 +67,7 @@ export class GameState {
this.players = players;
this.rounds = rounds;
this.bumpWind = 0;
this.prevailing = "E";
this.prevaling = "E";
}
static newGame(playerNames) {
@ -80,21 +78,7 @@ export class GameState {
}
let rounds = [];
let gs = 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;
return new GameState(players, rounds);
}
determineNextRound(playerScoreExprs) {
@ -108,7 +92,7 @@ export class GameState {
}
let windsBump = roundWinner !== currentEast;
let nextPrevailing = this.prevailing;
let nextPrevailing = this.prevaling;
if (windsBump) {
nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0];
}
@ -149,24 +133,14 @@ export class GameState {
this.players[pi].wind = windDistribution[i];
}
this.prevailing = nr.nextPrevailing;
this.prevaling = nr.nextPrevailing;
if (nr.windsBump) {
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() {
return gameState;

View file

@ -1,7 +1,3 @@
.container {
margin-block-end: 20px;
}
.hidden {
display: none;
}
@ -22,41 +18,4 @@
gap: 10px;
justify-content: space-between;
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;
}
}

View file

@ -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>

View file

@ -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"));
}
}

View file

@ -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}`;
}
}

View file

@ -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);

View file

@ -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('');
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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;
}