From 3652cf0567ecfc1ec85d9cf76f97bf60f30d545d Mon Sep 17 00:00:00 2001 From: Max Muehlbauer Date: Sun, 5 May 2024 21:56:17 +0200 Subject: [PATCH] add controller page --- package-lock.json | 9 + package.json | 1 + src/pages/controller.html | 339 +++++++++++++++++++++++++++++++++++++- src/scripts/controller.ts | 98 +++++++++++ src/scripts/gameState.ts | 38 ++++- src/scripts/main.ts | 2 + src/scripts/storage.ts | 4 + src/scripts/types.ts | 8 +- src/styles/pico-edit.css | 43 +++++ tsconfig.json | 15 +- 10 files changed, 535 insertions(+), 22 deletions(-) create mode 100644 src/scripts/controller.ts create mode 100644 src/styles/pico-edit.css diff --git a/package-lock.json b/package-lock.json index 38967df..8815ef8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "familienduell", "version": "0.0.0", "dependencies": { + "@picocss/pico": "^2.0.6", "alpinejs": "^3.13.10", "normalize.css": "^8.0.1" }, @@ -422,6 +423,14 @@ "node": ">= 8" } }, + "node_modules/@picocss/pico": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.0.6.tgz", + "integrity": "sha512-/d8qsykowelD6g8k8JYgmCagOIulCPHMEc2NC4u7OjmpQLmtSetLhEbt0j1n3fPNJVcrT84dRp0RfJBn3wJROA==", + "engines": { + "node": ">=18.19.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.0.tgz", diff --git a/package.json b/package.json index 5af076f..e13732d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vituum": "^1.1.0" }, "dependencies": { + "@picocss/pico": "^2.0.6", "alpinejs": "^3.13.10", "normalize.css": "^8.0.1" } diff --git a/src/pages/controller.html b/src/pages/controller.html index 60ac843..e380519 100644 --- a/src/pages/controller.html +++ b/src/pages/controller.html @@ -6,16 +6,341 @@ Familienduell - - + + -
-
- stay tuned -
-
+
+

Controller

+
+
+
+
+
+
+
+

Game view

+

Switch between question mode and highscore.

+
+
+
+ + + + +
+
+
+
+
+

Game progress

+

See how the game has progressed.

+
+
+
+ + + + + +
+
+
+
+
+
+ +
+
+

Game plan

+

Change the teams for each question.

+
+
+
+
+ + + + + + + + + + + +
QuestionTeam ATeam B
+
+
+
+
+
+
+
+ +
+
+

Highscore

+

See how the teams compete against each other.

+
+
+
+
+ + + + + + + + + + + +
PositionTeamPoints
+
+
+
+
+
+
+
+
+

Question control area

+

Control the game here.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Question
No.RevealedAnswerPoints
Points + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Team ATeam B
NameFailsNameFails
+ + + + + +
Actions +
+ + + + +
+
+
+ + + +
+
+
+
+
+ +
+
+

Question selection

+
+
+
+
+
⚠ You should select a winner first!
+
+
+ + +
+
+
+
+
+ +
+
+

Danger zone

+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
+
+ +
+
+

Critical actions

+

Use these actions carefully.

+
+
+
+
+ +
+
+
+
+
+
diff --git a/src/scripts/controller.ts b/src/scripts/controller.ts new file mode 100644 index 0000000..8d80d28 --- /dev/null +++ b/src/scripts/controller.ts @@ -0,0 +1,98 @@ +import { storage } from "./storage"; +import { DynamicGameState, DynamicQuestionState } from "./types"; +import Alpine from "alpinejs"; + +export function initControllerData() { + Alpine.data("controller", (state: DynamicGameState) => { + return { + get view() { + return state.currentView; + }, + set view(view: DynamicGameState["currentView"]) { + state.changeView(view); + }, + progress: { + questions: { + get max() { + return state.questions.length; + }, + get value() { + return state.activeQuestion + 1; + } + }, + points: { + total: { + get max() { + return state.questions.reduce((accumulator, { maximumPoints }) => accumulator + maximumPoints, 0); + }, + get value() { + return state.teams.reduce((accumulator, { points }) => accumulator + points, 0); + } + } + } + }, + get currentQuestion() { + return state.questions[state.activeQuestion]; + }, + get plan() { + return state.questions.map((question: DynamicQuestionState) => { + return { + get teamA() { + return { + get id() { + return question._teamA; + }, + set id(value) { + question._teamA = value; + }, + get name() { + return question.teamA?.name; + } + } + }, + get teamB() { + return { + get id() { + return question._teamB; + }, + set id(value) { + question._teamB = value; + }, + get name() { + return question.teamB?.name; + } + } + } + } + }) + }, + get teams() { + return state.teams.map(({ id, name, points }) => { + return { + id, + name, + points + } + }); + }, + get rankedTeams() { + return this.teams.toSorted((a, b) => b.points - a.points); + }, + deleteGame() { + return () => { + const shouldDelete = confirm("Do you really wish to delete the entire game state, including teams, questions and given answers and points?"); + if (shouldDelete) { + storage.delete("game"); + location.reload(); + } + } + }, + nextQuestion() { + state.nextQuestion(); + }, + prevQuestion() { + state.prevQuestion(); + } + } + }); +} \ No newline at end of file diff --git a/src/scripts/gameState.ts b/src/scripts/gameState.ts index 5456227..498ee37 100644 --- a/src/scripts/gameState.ts +++ b/src/scripts/gameState.ts @@ -44,8 +44,11 @@ function buildFailsCount(failsCount: StorableFailState): DynamicFailState { return { ...failsCount, async increase() { - this.failCount = (this.failCount + 1) > 3 ? 0 : this.failCount + 1; + this.failCount = Math.min((this.failCount + 1), 3); await playAudio("fail.mp3"); + }, + decrease() { + this.failCount = Math.max((this.failCount - 1), 0); } } } @@ -77,6 +80,16 @@ function buildQuestion(question: StorableQuestionState): DynamicQuestionState { get teamB() { return state.getById?.(this._teamB); }, + get winnerTeam() { + if (this._winnerTeam) { + return state.getById?.(this._winnerTeam) || null; + } + + return null; + }, + get closed() { + return !!this.winnerTeam; + }, clear() { this.answers.forEach(answer => { answer.reset(); @@ -84,6 +97,13 @@ function buildQuestion(question: StorableQuestionState): DynamicQuestionState { Object.values(this.fails).forEach(fail => { fail.failCount = 0; }); + this.winnerTeam?.addPoints(this.pointsWon * -1); + this._winnerTeam = null; + }, + win(teamId: WithID["id"], points: number) { + this._winnerTeam = teamId; + this.pointsWon = points; + this.winnerTeam?.addPoints(points); } } } @@ -116,7 +136,7 @@ function buildTeam(team: StorableTeamState): DynamicTeamState { return trimmedName; }, addPoints(amount: number) { - this.points = this.points + amount; + this.points = Math.max(this.points + amount, 0); } } } @@ -167,14 +187,12 @@ function buildGameStateFromJSON(inputState: StorableGameState): DynamicGameState teams: inputState.teams.map(team => buildTeam(team)), questions: inputState.questions.map(question => buildQuestion(question)), get ranking() { - return this.teams.sort((a, b) => b.points - a.points); + return this.teams.toSorted((a, b) => b.points - a.points); }, prevQuestion() { - (this.questions[this.activeQuestion] as DynamicQuestionState).clear(); this.activeQuestion = this.activeQuestion <= 0 ? 0 : this.activeQuestion - 1; }, nextQuestion() { - (this.questions[this.activeQuestion] as DynamicQuestionState).clear(); this.activeQuestion = this.activeQuestion >= this.questions.length - 1 ? this.questions.length - 1 : this.activeQuestion + 1; }, getById(id: T["id"]): T | undefined { @@ -221,7 +239,9 @@ function buildDefaultGameState(): DynamicGameState { { id: getStateId("answer"), solution: "Schlitten", points: 79, open: false }, { id: getStateId("answer"), solution: "Pferd", points: 69, open: true }, { id: getStateId("answer"), solution: "Jetpack mit Festbrennstoffraketen-Antrieb", points: 59, open: false } - ] + ], + _winnerTeam: null, + pointsWon: 0, }, { id: getStateId("question"), @@ -244,7 +264,9 @@ function buildDefaultGameState(): DynamicGameState { { id: getStateId("answer"), solution: "Ohne Hose rumlaufen", points: 42, open: false }, { id: getStateId("answer"), solution: "Pferd", points: 33, open: false }, { id: getStateId("answer"), solution: "Wäsche machen / Putzen", points: 24, open: false } - ] + ], + _winnerTeam: null, + pointsWon: 0, } ] }); @@ -350,6 +372,4 @@ export function initGameState(id: string = "game") { const newState = deepMergeGameState(state, JSON.parse(newValue)); console.log(newState); }); - - window["state"] = state; } \ No newline at end of file diff --git a/src/scripts/main.ts b/src/scripts/main.ts index 2bd88f0..be23a6f 100644 --- a/src/scripts/main.ts +++ b/src/scripts/main.ts @@ -1,6 +1,7 @@ import Alpine from "alpinejs"; import { revealDirective } from "./reveal-directive"; import { initGameState } from "./gameState"; +import { initControllerData } from "./controller"; // @ts-ignore window["Alpine"] = Alpine; @@ -8,5 +9,6 @@ window["Alpine"] = Alpine; Alpine.directive("reveal", revealDirective); initGameState(); +initControllerData(); Alpine.start(); \ No newline at end of file diff --git a/src/scripts/storage.ts b/src/scripts/storage.ts index 64165d5..96be305 100644 --- a/src/scripts/storage.ts +++ b/src/scripts/storage.ts @@ -49,6 +49,10 @@ class StorageAPI { callback({ key, oldValue, newValue }); }); } + + delete(key: string) { + localStorage.removeItem(key); + } } export const storage = new StorageAPI(); \ No newline at end of file diff --git a/src/scripts/types.ts b/src/scripts/types.ts index 4f9c346..8048d7a 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -33,7 +33,8 @@ export interface StorableFailState extends WithID { } export interface DynamicFailState extends StorableFailState { - increase(): Promise + increase(): Promise; + decrease(): void; } export interface StorableQuestionState extends WithID { @@ -45,6 +46,8 @@ export interface StorableQuestionState extends WithID { }; _teamA: WithID["id"]; _teamB: WithID["id"]; + _winnerTeam: WithID["id"] | null; + pointsWon: number; } export interface DynamicQuestionState extends StorableQuestionState { @@ -57,7 +60,10 @@ export interface DynamicQuestionState extends StorableQuestionState { readonly maximumPoints: number; readonly teamA?: DynamicTeamState; readonly teamB?: DynamicTeamState; + readonly winnerTeam: DynamicTeamState | null; + readonly closed: boolean; clear(): void; + win(teamId: WithID["id"], points: number): void; } export interface StorableGameState extends WithID { diff --git a/src/styles/pico-edit.css b/src/styles/pico-edit.css new file mode 100644 index 0000000..8455b7c --- /dev/null +++ b/src/styles/pico-edit.css @@ -0,0 +1,43 @@ +article hgroup { + --pico-typography-spacing-vertical: 0; +} + +article :is(h1, h2, h3, h4, h5, h6) { + --pico-font-size: 1.1rem; +} + +td>select:last-child { + --pico-spacing: 0; +} + +details>summary { + position: relative; + padding-inline-end: calc(1rem + var(--pico-spacing)); +} + +details>summary::after { + position: absolute; + float: none; + margin: 0; + inset-inline-end: 0; + inset-block-start: calc(50% - 0.5rem); +} + +article>details { + margin: 0; +} + +fieldset:last-child { + margin-bottom: 0; +} + +table:last-child { + margin-bottom: 0; +} + +tfoot [type="button"] { + --pico-font-weight: 400; + --pico-form-element-spacing-vertical: 0.25rem; + --pico-form-element-spacing-horizontal: 0.5rem; + font-size: 0.7rem; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 75abdef..4df5657 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,21 +3,26 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": [ + "ES2023.Array", + "ES2020", + "DOM", + "DOM.Iterable" + ], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file