Compare commits

..

No commits in common. "main" and "gh-pages" have entirely different histories.

38 changed files with 5 additions and 11775 deletions

View file

@ -1,20 +0,0 @@
const { join } = require("node:path");
const { PluginName, PresetName } = require("@manuth/eslint-plugin-typescript");
module.exports = {
root: true,
extends: [
`plugin:${PluginName}/${PresetName.RecommendedWithTypeChecking}`
],
env: {
node: true,
browser: true
},
parserOptions: {
project: [
join(__dirname, "app.jsconfig.json"),
join(__dirname, "eslint.jsconfig.json"),
join(__dirname, "gulp.tsconfig.json")
]
}
};

11
.gitignore vendored
View file

@ -1,6 +1,4 @@
# Build results # ---> Node
[Ll]ib/
# Logs # Logs
logs logs
*.log *.log
@ -96,7 +94,7 @@ dist
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
@ -132,8 +130,3 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# Temporary release-assets
.tagName.txt
.tagHeading.txt
.releaseNotes.md
.releaseTitle.md

View file

@ -1,168 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Source-files
[Ss]rc/
# TypeScript config-files
tsconfig.json
tsconfig.*.json
# Lint config-files
.eslintrc
.eslintrc.*
# Source-maps
[Ll]ib/**/*.map
# Unit-Tests
.mocharc.*
[Ll]ib/tests/
# Visual Studio Code-Environment
.vscode/
# GitHub configuration
.github/
# CI configuration
.drone.yml
.woodpecker.yml
# Build Environment
gulp/
gulpfile.ts
# Temporary release-assets
.tagName.txt
.tagHeading.txt
.releaseNotes.md
.releaseTitle.md

View file

@ -1,8 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"hbenl.test-adapter-converter",
"hbenl.vscode-mocha-test-adapter",
"hbenl.vscode-test-explorer"
]
}

19
.vscode/launch.json vendored
View file

@ -1,19 +0,0 @@
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Website in Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/lib/static",
"preLaunchTask": "Build",
"pathMapping": {
"/": "${workspaceFolder}/src"
}
}
]
}

13
.vscode/settings.json vendored
View file

@ -1,13 +0,0 @@
{
"javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false,
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": true,
"javascript.format.placeOpenBraceOnNewLineForFunctions": true,
"typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false,
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": true,
"typescript.format.placeOpenBraceOnNewLineForFunctions": true,
"html.format.extraLiners": "",
"html.format.indentInnerHtml": true,
"html.format.maxPreserveNewLines": 1,
"html.format.wrapAttributes": "preserve-aligned",
"npm.packageManager": "npm"
}

76
.vscode/tasks.json vendored
View file

@ -1,76 +0,0 @@
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
"version": "2.0.0",
"tasks": [
{
"label": "Build",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}"
},
"command": "npm",
"args": [
"run",
"watch"
],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [
{
"owner": "gulp",
"pattern": {
"regexp": ""
},
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Starting '(?!Watch).*?'"
},
"endsPattern": {
"regexp": "Finished '.*?'"
}
}
}
],
"isBackground": true,
"presentation": {
"reveal": "never"
}
},
{
"label": "Rebuild",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}"
},
"command": "npm",
"args": [
"run",
"rebuild"
],
"problemMatcher": [],
"presentation": {
"reveal": "never"
}
},
{
"label": "Lint",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}"
},
"command": "npm",
"args": [
"run",
"lint-ide"
],
"problemMatcher": "$eslint-stylish",
"presentation": {
"reveal": "never"
}
}
]
}

View file

@ -1,29 +0,0 @@
pipeline:
install:
image: node
commands:
- npm install
build:
image: node
commands:
- npm run build
lint:
image: node
commands:
- npm run lint
publish:
image: plugins/gh-pages
environment:
PLUGIN_REMOTE_URL: git@github.zhaw.ch:thalmma5/ConnectForce.git
settings:
username: thalmma5
password:
from_secret:
github_token
ssh_key:
from_secret:
github_sshkey
target_branch: gh-pages
pages_directory: ./lib/static
when:
branch: main

View file

@ -1,8 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## ConnectForce [Unreleased]
- Initial release

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2022 Manuel Thalmann Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,2 +1,3 @@
# ConnectForce # ConnectForce
A selfmade Connect Four game. A selfmade Connect Four game.

View file

@ -1,14 +0,0 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"composite": true,
"lib": [
"DOM"
]
},
"include": [
"./src/js/**/*"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,152 +0,0 @@
---
Header:
Right: ConnectForce
---
# ConnectForce Dokumentation
> `ConnectForce` ist eine grossartige Umsetzung des allzeit beliebten Spiels namens "4 Gewinnt!".
> In diesem strategisch anspruchsvollen Spiel müssen Spieler ihre Spielsteine möglichst geschickt platzieren, um so 4 von ihnen zu verbinden und sich so als dem Gegner überlegen zu beweisen!
>
> Wirst du dazu imstande sein, über dem IQ und der Geschicklichkeit deines Gegners zu obsiegen?
Für die Umsetzung von `ConnectForce` wurden Standards und Technologien wie etwa `SJDON`, `SuiWeb`, JavaScript, CSS und HTML verwendet.
Das Ziel dieses Dokuments ist es, aufzuzeigen, welche Aspekte des Projekts in ihrer Umsetzung herausfordernd waren, wie diese Herausforderungen in Angriff genommen wurden und welche Fortschritte besonders erwähnenswert sind.
## Beteiligte Personen
Das Projekt wurde von folgenden Personen umgesetzt:
- Manuel Thalmann
Allgemeine Umsetzung, Programmierung, Design
- Jonas Costa
Bilder, Werbung
## Das Endprodukt
Das Endprodukt ist ein "4 Gewinnt!" Spiel, welches sich in der Grösse automatisch so skaliert, dass es sich auf jeglichen Geräten übersichtlich betrachten und auch steuern lässt (wie etwa Tablets, Handys, Computer, Ultra Widescreen Monitore etc.).
Folgender Screenshot zeigt, wie die Anwendung in einem Chromium Browser aussieht:
![](Preview.png)
## Features
Die Anwendung bringt einige nennenswerte Funktionen mit sich. Zu diesen zählen folgende:
- Die Fähigkeit, das Spiel jederzeit neu zu starten
- Die Möglichkeit, das Spiel im lokalen Speicher des Browsers zu speichern und aus diesem zu laden
- Die Funktion, einzelne Änderungen, die am Zustand des Spiels vorgenommen wurden, rückgängig zu machen
## Nutzung
Die Anwendung wird mit Hilfe der `SJDON`-Notation zur Verfügung gestellt und kann somit problemlos mit der Bibliothek `SuiWeb` dargestellt und ausgeführt werden:
```js
import { App } from "./Components.js";
import { render } from "./SuiWeb.js";
/**
* Initializes the board.
*/
function initialize()
{
render([App], document.querySelector("#game"));
}
initialize();
```
## Herausforderungen
Einige Aspekte des Spiels stellten sich, obwohl es zunächst nicht den Anschein machte, als Herausforderungen heraus. Eine davon wird in diesem Kapitel im Details beschrieben.
### Bestimmung des Gewinners
Teil der Aufgabe war es, anhand des Zustandes des Spielfelds zu entscheiden, ob und welcher Spieler gewonnen hat.
Der Weg für die Bestimmung des Gewinners, welcher in diesem Projekt verwendet wurde, wird im Folgenden genauer erklärt.
#### Grundlagen
Es gibt genau 4 verschiedene Arten, in denen Figuren zueinander stehen können:
| Bezeichnung | Relative Koordinaten |
| --------------------- | -------------------- |
| Horizontal | `[0, 1]` |
| Vertikal | `[1, 0]` |
| Diagonal (nach unten) | `[1, 1]` |
| Diagonal (nach oben) | `[1, -1]` |
Folgende Bilder dienen der Illustration:
- Horizontal:
![](./Horizontal.png)
- Vertikal:
![](./Vertical.png)
- Diagonal (nach unten):
![](./DiagonalDown.png)
- Diagonal (nach oben):
![](./DiagonalUp.png)
Der Fakt, dass Chips nur in diesen 4 Relationen auftreten können, wird genutzt, um mit Hilfe mehrerer Iterationen zu prüfen, ob sich jeweils 4 Felder mit den genannten relativen Koordinaten zueinander in Relation stehen.
Für die Kontrolle, ob eine Person gewonnen hat, wird folgender Algorithmus verwendet:
```js
// Iterate over all possible relative coordinates.
for (let yOffset = 0; yOffset <= 1; yOffset++)
{
for (let xOffset = (yOffset === 1) ? -1 : 1; xOffset <= 1; xOffset++)
{
// Calculate upper and lower bounds of the x-axis.
let lowerBound = Math.max(0, xOffset * (Game.#count - 1) * -1);
let upperBound = Math.min(Game.#width, Game.#width - (xOffset * (Game.#count - 1)));
// Iterate over all possible x- and y-coordinates.
for (let y = 0; y < (Game.#height - yOffset * (Game.#count - 1)); y++)
{
for (let x = lowerBound; x < upperBound; x++)
{
/**
* @type {CellOwner[]}
*/
let tokens = [];
// Generate a list of all tokens in the current scope.
for (let i = 0; i < Game.#count; i++)
{
tokens.push(this.board[y + i * yOffset][x + i * xOffset]);
}
let player = tokens[0];
// Check whether the tokens indicate a win.
if (
player !== "" &&
tokens.every((token) => token === player))
{
return player;
}
}
}
}
}
return null;
```
Dieser Algorithmus ist dazu imstande, zu bestimmen, ob und welcher Spieler gewonnen hat. Dies funktioniert unabhängig davon, ob die Steine horizontal, vertikal oder diagonal verbunden sind.
Einige Kommentare im Code sollen dabei behilflich sein, die Funktionalität zu verstehen.
## Umsetzung
Für das Umsetzen des Projekts wurde die Notation `SJDON` verwendet zusammen mit der ausgelieferten Bibliothek `SuiWeb` verwendet.
Komponenten mit Besonderheiten werden im folgenden Kapitel genauer beschrieben.
### Komponenten
#### App
Die App-Komponente beinhaltet das gesamte Spiel-Konstrukt, zeigt den aktuellen Zustand des Spiels an und verarbeitet Interaktionen, die der Benutzer mit dem Spiel macht.
Dies beinhaltet alle zuvor genannten Funktionen und das Vollziehen eines Spielzuges.
Zudem wird ein zum Aufrufenden ideal passendes Produkt in einem Werbungs-Banner angezeigt.
## MenuBar
Die Menü-Leiste erlaubt es dem Besucher, die von ihm gewünschten Funktionen auszuführen.
Sollte eine Funktion nicht verfügbar sein, wird dies dem Nutzer auf verständlicher Weise angezeigt.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,10 +0,0 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true
},
"include": [
"./.eslintrc.cjs"
]
}

View file

@ -1,7 +0,0 @@
{
"extends": "./tsconfig.base.json",
"include": [
"./gulpfile.ts",
"./gulp/**/*"
]
}

View file

@ -1,143 +0,0 @@
import { fileURLToPath } from "node:url";
import path from "upath";
const { join } = path;
/**
* Represents the context of the build system.
*/
export class Context
{
/**
* The directory containing the source files.
*/
private sourceRoot = "src";
/**
* The directory containing the built files.
*/
private outRoot = "lib";
/**
* The name of the directory containing static assets.
*/
private staticRoot = "static";
/**
* The name of the directory containing javascript files.
*/
private jsDir = "js";
/**
* The name of the directory containing css files.
*/
private styleDir = "styles";
/**
* The name of the directory containing assets.
*/
private assetDir = "assets";
/**
* Initializes a new instance of the {@link Context `Context`} class.
*/
public constructor() { }
/**
* Gets the path to the root of the project.
*/
public get ProjectRoot(): string
{
return join(fileURLToPath(new URL(".", import.meta.url)), "..");
}
/**
* Gets the directory containing the source files.
*/
public get SourceRoot(): string
{
return join(this.ProjectRoot, this.sourceRoot);
}
/**
* Gets the directory containing the built files.
*/
public get OutRoot(): string
{
return join(this.ProjectRoot, this.outRoot);
}
/**
* Gets the path of the directory containing static assets.
*/
public get StaticRoot(): string
{
return join(this.OutRoot, this.staticRoot);
}
/**
* Gets the name of the directory containing javascript files.
*/
public get JSDirName(): string
{
return this.jsDir;
}
/**
* Gets the name of the directory containing css files.
*/
public get StyleDirName(): string
{
return this.styleDir;
}
/**
* Gets the name of the directory containing assets.
*/
public get AssetDirName(): string
{
return this.assetDir;
}
/**
* Creates a path relative to the {@link SourceRoot `SourceRoot`}.
*
* @param path
* The path to join.
*
* @returns
* The resulting path.
*/
public SourcePath(...path: string[]): string
{
return join(this.SourceRoot, ...path);
}
/**
* Creates a path relative to the {@link OutRoot `OutRoot`}.
*
* @param path
* The path to join.
*
* @returns
* The resulting path.
*/
public OutPath(...path: string[]): string
{
return join(this.OutRoot, ...path);
}
/**
* Creates a path relative to the {@link StaticRoot `StaticRoot`}.
*
* @param path
* The path to join.
*
* @returns
* The resulting path.
*/
public StaticPath(...path: string[]): string
{
return join(this.StaticRoot, ...path);
}
}

View file

@ -1,202 +0,0 @@
import browserSync, { BrowserSyncInstance } from "browser-sync";
import GulpClient, { TaskFunction } from "gulp";
import path from "upath";
import { Context } from "./gulp/Context.js";
const { dest, parallel, series, src, watch } = GulpClient;
const { join } = path;
const context = new Context();
/**
* Builds documentation page.
*
* @returns
* The resulting stream.
*/
export function Docs(): NodeJS.ReadWriteStream
{
return src(join(context.ProjectRoot, "docs", "**", "*")).pipe(
dest(context.StaticPath("docs")));
}
/**
* Builds javascript files.
*
* @returns
* The resulting stream.
*/
export function JavaScript(): NodeJS.ReadWriteStream
{
return src(context.SourcePath(context.JSDirName, "**", "*.js")).pipe(
dest(context.StaticPath(context.JSDirName)));
}
/**
* Builds css files.
*
* @returns
* The resulting stream.
*/
export function Styles(): NodeJS.ReadWriteStream
{
return src(context.SourcePath(context.StyleDirName, "**", "*.css")).pipe(
dest(context.StaticPath(context.StyleDirName)));
}
/**
* Builds asset files.
*
* @returns
* The resulting stream.
*/
export function Assets(): NodeJS.ReadWriteStream
{
return src(context.SourcePath(context.AssetDirName, "**", "*")).pipe(
dest(context.StaticPath(context.AssetDirName)));
}
/**
* Builds the icon of the webpage.
*
* @returns
* The resulting stream.
*/
export function Icon(): NodeJS.ReadWriteStream
{
return src(context.SourcePath("favicon.ico")).pipe(
dest(context.StaticPath()));
}
/**
* Builds the web pages.
*
* @returns
* The resulting stream.
*/
export function WebPages(): NodeJS.ReadWriteStream
{
return src(context.SourcePath("**", "*.html")).pipe(
dest(context.StaticPath()));
}
/**
* Builds all files.
*/
export function Build(): Promise<void>
{
return new Promise<void>(
(resolve, reject) =>
{
parallel(
[
JavaScript,
Styles,
Assets,
Icon,
WebPages,
Docs
])(
(error) =>
{
if (error)
{
reject(error);
}
else
{
resolve();
}
});
});
}
/**
* Reloads all browsers using `browser-sync`.
*
* @param syncer
* The browser-sync instance to work on.
*
* @param filePath
* A glob-path which points to the files which must be reloaded.
*
* @returns
* The actual task.
*/
function BrowserSync(syncer: BrowserSyncInstance, filePath?: string): TaskFunction
{
let BrowserSync: TaskFunction = (done) =>
{
if (filePath)
{
syncer.reload(filePath);
}
else
{
syncer.reload();
}
done();
};
return BrowserSync;
}
/**
* Builds and watches the files for changes.
*/
export let Watch: TaskFunction = async (): Promise<void> =>
{
return new Promise<void>(
(resolve, reject) =>
{
series(Build)(
(error) =>
{
if (error)
{
reject(error);
}
else
{
let syncer = browserSync.create();
syncer.init({
open: false,
server: join(context.StaticPath()),
online: false
});
watch(
context.SourcePath(context.JSDirName),
series(
JavaScript,
BrowserSync(syncer, "*.js")));
watch(
context.SourcePath(context.StyleDirName, "**", "*.css"),
series(
Styles,
BrowserSync(syncer, "*.css")));
watch(
context.SourcePath(context.AssetDirName),
series(
Assets,
BrowserSync(syncer, join(context.AssetDirName, "**", "*"))));
watch(
context.SourcePath("favicon.ico"),
series(
Icon,
BrowserSync(syncer, join("favicon.ico"))));
watch(
context.SourcePath("**", "*.html"),
series(
WebPages,
BrowserSync(syncer, "*.html")));
}
});
});
};

9733
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
{
"name": "connect-force",
"version": "0.0.0",
"type": "module",
"description": "A selfmade Connect Four game.",
"author": "Manuel Thalmann <m@nuth.ch>",
"scripts": {
"gulp": "cross-env NODE_OPTIONS=\"--loader ts-node/esm\" gulp --",
"build": "npm run gulp Build",
"rebuild": "npm run clean && npm run build",
"watch": "npm run gulp Watch",
"clean": "rimraf ./lib",
"lint": "eslint --max-warnings 0 ./src .eslintrc.cjs",
"lint-ide": "npm run lint || exit 0",
"prepare": "npm run rebuild"
},
"devDependencies": {
"@manuth/eslint-plugin-typescript": "^4.0.1",
"@manuth/tsconfig": "^3.0.2",
"@types/browser-sync": "^2.26.3",
"@types/gulp": "^4.0.10",
"@types/node": "^18.11.11",
"browser-sync": "^2.27.10",
"cross-env": "^7.0.3",
"eslint": "^8.29.0",
"gulp": "^4.0.2",
"rimraf": "^3.0.2",
"ts-node": "^10.9.1",
"upath": "^2.0.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

View file

@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vier gewinnt</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="./styles/style.css" />
<script type="module" src="./js/main.js"></script>
</head>
<body>
<div id="game">
</div>
</body>
</html>

View file

@ -1,250 +0,0 @@
import { Constants } from "./Constants.js";
import { Game } from "./Game.js";
import { useState } from "./SuiWeb.js";
const saveGameKey = "connect-force-save";
const newGameClass = "new-game";
const saveGameClass = "save-game";
const loadGameClass = "load-game";
const undoClass = "undo-game";
/**
* Gets a component which represents game.
*
* @returns {NodeDescriptor}
* The rendered node.
*/
export function App()
{
const [game, setGame] = /** @type {[Game, (updateFunction: (game: Game) => void) => void]} */ (useState("game", "game", new Game()));
return [
"div",
{
onclick: (event) =>
{
let target = /** @type {HTMLElement} */ (event.target);
if (target.classList.contains(newGameClass))
{
setGame(
(game) =>
{
game.reset();
return game;
});
}
else if (target.classList.contains(saveGameClass))
{
localStorage.setItem(saveGameKey, JSON.stringify(game.dump()));
}
else if (target.classList.contains(loadGameClass))
{
setGame(
(game) =>
{
game.load(JSON.parse(localStorage.getItem(saveGameKey)));
return game;
});
}
else if (target.classList.contains(undoClass))
{
setGame(
(game) =>
{
game.undo();
return game;
});
}
else
{
for (let y = 0; y < game.board.length; y++)
{
let rowLength = game.board[y].length;
for (let x = 0; x < rowLength; x++)
{
let node = document.querySelector(".board").children.item(y * rowLength + x);
if (node === target || node.contains(target))
{
setGame(
(game) =>
{
game.addChip(x, y);
return game;
});
}
}
}
}
}
},
{
className: `game ${game.winner ? `winner ${game.winner}` : game.currentPlayer}`
},
[
Board,
{ board: game.board }
],
[
"div",
{ className: "log" },
game.winner ?
`Player ${Constants.PLAYER_NAMES[game.winner]} wins!` :
`It's player "${Constants.PLAYER_NAMES[game.currentPlayer]}"s turn`
],
[MenuBar, { game }],
[Ad],
[
"div",
{ className: "links" },
[
"a",
{
href: "./docs/Documentation"
},
"Documentation"
]
]
];
}
/**
* Renders an element which represents the specified {@link board `board`}.
*
* @param {{ board: Board }} board
* The board represented in this element.
*
* @returns {NodeDescriptor}
* The rendered element.
*/
export function Board({ board })
{
let fields = board.flatMap((row) => row);
return [
"div",
{ className: "board" },
...fields.map(
(field) =>
{
return /** @type {NodeDescriptor} */([Field, { field }]);
}),
["div", { style: "clear: both;" }]
];
}
/**
* Renders an element which represents the specified {@link field `field`}.
*
* @param {{ field: CellOwner }} field
* The field to represent.
*
* @returns {NodeDescriptor}
* The rendered element.
*/
export function Field({ field })
{
return [
"div",
{ className: "field" },
...(
field !== "" ?
[
/** @type {NodeDescriptor} */
([
"div",
{ className: `piece ${Constants.PLAYER_NAMES[field]}` }
])
] :
[])
];
}
/**
* Renders a menu bar.
*
* @param {{ game: Game }} game
* The game to represent.
*
* @returns {NodeDescriptor}
* The rendered element.
*/
export function MenuBar({ game })
{
return [
"div",
{ className: "menu-bar" },
[Button, { content: ["New Game", { className: newGameClass }] }],
[Button, { content: ["Save Game", { className: saveGameClass }] }],
[Button, { content: ["Load Game", { className: loadGameClass }] }],
[
Button,
{
content: [
"Undo Last Move",
{
className: undoClass,
...(game.undoStackCount > 0 ? {} : { disabled: true })
}
]
}
]
];
}
/**
* Renders a button.
*
* @param {{ content: ElementDescriptor[1][] }} content
* The settings of the button.
*
* @returns {NodeDescriptor}
* The rendered element.
*/
export function Button({ content })
{
return [
"button",
...content
];
}
/**
* Renders a very serious ad.
*
* @returns {NodeDescriptor}
* The rendered ad.
*/
export function Ad()
{
return [
"div",
{
className: "ad",
style: "text-align: center;"
},
[
"div",
{
style: "display: inline-block; position: relative;"
},
[
"a",
{
href: "https://www.youtube.com/watch?v=Wgt7JQdymsQ",
target: "_blank"
},
["img", { src: "./assets/ad.gif" }],
[
"img",
{
src: "./assets/adInfo.png",
style: "position: absolute; top: 0; right: 0;"
}
]
]
]
];
}

View file

@ -1,15 +0,0 @@
/**
* Provides constants for the project.
*/
export class Constants
{
/**
* The IDs of the players.
*
* @type {Partial<Record<CellOwner, string>>}
*/
static PLAYER_NAMES = {
r: "red",
b: "blue"
};
}

View file

@ -1,251 +0,0 @@
import { State } from "./State.js";
/**
* Represents a game.
*/
export class Game
{
/**
* The number of chips required for a win.
*/
static #count = 4;
/**
* The width of the board.
*/
static #width = 7;
/**
* The height of the board.
*/
static #height = 6;
/**
* The previous states of the game.
*
* @type {IState[]}
*/
#previousStates = [];
/**
* The state of the game.
*
* @type {IState}
*/
#state;
/**
* Initializes a new instance of the {@link Game `Game`} class.
*/
constructor()
{
this.#state = State.create(Game.#width, Game.#height);
}
/**
* Gets the state of the game.
*/
get state()
{
return this.#state;
}
/**
* Gets the board of the game.
*/
get board()
{
return this.state.board;
}
/**
* Gets the current player.
*
* @type {CellOwner}
*/
get currentPlayer()
{
return this.state.turnCount % 2 === 0 ? "r" : "b";
}
/**
* Gets the number of entries in the undo stack.
*/
get undoStackCount()
{
return this.#previousStates.length;
}
/**
* Gets the id of the player that is winning.
*
* @type {CellOwner}
*/
get winner()
{
for (let yOffset = 0; yOffset <= 1; yOffset++)
{
for (let xOffset = (yOffset === 1) ? -1 : 1; xOffset <= 1; xOffset++)
{
let lowerBound = Math.max(0, xOffset * (Game.#count - 1) * -1);
let upperBound = Math.min(Game.#width, Game.#width - (xOffset * (Game.#count - 1)));
for (let y = 0; y < (Game.#height - yOffset * (Game.#count - 1)); y++)
{
for (let x = lowerBound; x < upperBound; x++)
{
/**
* @type {CellOwner[]}
*/
let tokens = [];
for (let i = 0; i < Game.#count; i++)
{
tokens.push(this.board[y + i * yOffset][x + i * xOffset]);
}
let player = tokens[0];
if (
player !== "" &&
tokens.every((token) => token === player))
{
return player;
}
}
}
}
}
return null;
}
/**
* Dumps the state of the game.
*
* @returns {IState}
* The JSON string representing the state.
*/
dump()
{
return {
turnCount: this.state.turnCount,
board: this.state.board
};
}
/**
* Loads the game from the specified {@link data `data`}.
*
* @param {IState} data
* The data to load.
*/
load(data)
{
this.setState([], data);
if (!this.#state)
{
this.reset();
}
}
/**
* Resets the game.
*/
reset()
{
this.setState([], State.create(Game.#width, Game.#height));
}
/**
* Reverts the last move.
*/
undo()
{
this.#state = this.#previousStates.pop();
if (!this.#state)
{
this.reset();
}
}
/**
* Sets the value located at the specified {@link path `path`} to the specified {@link value `value`}.
*
* @param {(keyof any)[]} path
* The path of the value to set.
*
* @param {any} value
* The value to set.
*/
setState(path, value)
{
this.#previousStates.push(this.#state);
this.#state = { ...this.#state };
this.#state.turnCount++;
if (path.length === 0)
{
this.#state = value;
}
else if (path.length === 1)
{
this.#state = {
...this.#state,
[path[0]]: value
};
}
else
{
let target = /** @type {any} */ (this.#state);
while (path.length > 1)
{
if (Array.isArray(target[path[0]]))
{
target[path[0]] = [...target[path[0]]];
}
else
{
target[path[0]] = { ...target[path[0]] };
}
target = target[path[0]];
path = path.slice(1);
}
target[path[0]] = value;
}
}
/**
* Adds a chip to the board indicated by the {@link x `x`} and the {@link y `y`} coordinate.
*
* @param {number} x
* The x coordinate to add the chip to.
*
* @param {number} y
* The y coordinate to add the chip to.
*
* @returns {boolean}
* A value indicating whether the chip could be added.
*/
addChip(x, y)
{
if (!this.winner)
{
for (let i = Game.#height - 1; i >= 0; i--)
{
if (this.board[i][x] === "")
{
this.setState(["board", i, x], this.currentPlayer);
return true;
}
}
}
return false;
}
}

View file

@ -1,29 +0,0 @@
/**
* Represents the state of a game.
*/
export class State
{
/**
* Creates a new state.
*
* @param {number} width
* The width of the board.
*
* @param {number} height
* The height of the board.
*
* @returns {IState}
* The newly created state.
*/
static create(width, height)
{
return {
turnCount: 0,
board: Array(height).fill("").map(
() =>
{
return Array(width).fill("");
})
};
}
}

View file

@ -1,376 +0,0 @@
// @ts-nocheck
/* eslint-disable eslint-comments/no-unlimited-disable */
/* eslint-disable */
/**
* SuiWeb
* Simple User Interface Tool for Web Exercises
*
* @author bkrt
* @version 0.3.4
* @date 11.12.2021
*
* 0.3.4 - only null or undefined qualify as uninitialized state
* 0.3.3 - parseSJDON rewritten to use createElement
* - flatten children array for props.children in JSX
* 0.3.2 - save and restore of active element improved
* 0.3.1 - parseSJDON rewritten
* 0.3.0 - style property
* 0.2.3 - ES6 modules
* 0.2.2 - component state and hooks
* 0.2.1 - parse SJDON rewritten, function components
* 0.2.0 - render single SJDON structures to DOM
*
* Based on ideas and code from
* Rodrigo Pombo: Build your own React
* https://pomb.us/build-your-own-react/
*
* Thanks to Rodrigo Pombo for a great tutorial and for sharing the
* code of the Didact library. Didact is a much more sophisticated
* re-implementtation of React's basics than the simple SuiWeb.
*/
/* =====================================================================
* SJDON - Conversion
* =====================================================================
*/
// parseSJDON: convert SJDON to createElement calls
//
// note: this function can also help to use React with SJDON syntax
//
// to simplify calls add something like this to your components:
// let s = (data) => reactFromSJDON(data, React.createElement)
//
function parseSJDON([type, ...rest], create = createElement)
{
const isObj = (obj) => typeof (obj) === 'object' && !Array.isArray(obj)
const children = rest.filter(item => !isObj(item))
const repr = create(type,
Object.assign({}, ...rest.filter(isObj)),
...children.map(ch => Array.isArray(ch) ? parseSJDON(ch, create) : ch)
)
repr.sjdon = children
return repr
}
// create an element representation
//
function createElement(type, props, ...children)
{
return {
type,
props: {
...props,
children: children.flat().map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
// create a text element representation
//
function createTextElement(text)
{
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
/* =====================================================================
* Render node tree to DOM
* =====================================================================
*/
// global context
let contextStore = createStore();
// remove children and render new subtree
//
function render(element, container)
{
while (container.firstChild)
{
container.removeChild(container.lastChild);
}
renderInit(element, container, 0);
}
// render subtree
// and call effects after rendering
//
function renderInit(element, container, n, childIndex)
{
contextStore("effects", []);
// save focus and cursor position of active element
let [focusSelector, position] = getFocusInput();
// ** render the element **
renderElem(element, container, n, childIndex);
// restore focus and cursor position of active element
setFocusInput(focusSelector, position);
// run effects
contextStore("effects").forEach(fun => fun());
contextStore("effects", []);
}
// render an element
// - if it is in SJDON form: parse first
// - render function or host component
//
function renderElem(element, container, n, childIndex)
{
if (Array.isArray(element))
{
element = parseSJDON(element);
}
if (element.type instanceof Function)
{
updateFunctionComponent(element, container, n, childIndex);
} else
{
updateHostComponent(element, container, childIndex);
}
}
// function component
// - run function to get child node
// - render child node
//
function updateFunctionComponent(element, container, n, childIndex)
{
// save re-render function to context
contextStore("re-render", () => renderInit(element, container, n, n));
let children = element.sjdon ?? element.props.children;
let node = element.type({ ...element.props, children });
renderElem(node, container, n, childIndex);
}
// host component
// - create dom node
// - assign properties
// - render child nodes
// - add host to dom
//
function updateHostComponent(element, container, childIndex)
{
// create DOM node
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// assign the element props
const isProperty = key => key !== "children"
Object.keys(element.props)
.filter(isProperty)
.forEach(name =>
{
if (name == "style")
{
updateStyleAttribute(dom, element.props.style);
} else
{
dom[name] = element.props[name];
}
})
// render children
element.props.children.forEach((child, index) =>
{
renderElem(child, dom, index);
});
if (typeof (childIndex) == 'number')
{
// re-render: replace node
container.replaceChild(dom, container.childNodes[childIndex]);
} else
{
// add node to container
container.appendChild(dom);
}
}
// update style attribute, value can be:
// - a CSS string: set to style attribute
// - an object: merged to style attribute
// - an array of objects: merged to style attribute
//
function updateStyleAttribute(dom, styles)
{
if (typeof (styles) == "string")
{
dom.style = styles;
} else if (Array.isArray(styles))
{
Object.assign(dom.style, ...styles);
} else if (typeof (styles) == "object")
{
Object.assign(dom.style, styles);
}
}
/* =====================================================================
* Handling state
* =====================================================================
*/
// element state
let stateHooks = createStore();
// state hook
// - access state via id and key
// - return state and update function
//
function useState(id, key, init)
{
let idKey = "id:" + id + "-key:" + key;
// prepare render function
let renderFunc = contextStore("re-render");
// define function to update state
function updateValue(updateFun, rerender = true)
{
stateHooks(idKey, updateFun(stateHooks(idKey)));
if (rerender) renderFunc();
}
// new state: set initial value
if ([undefined, null].includes(stateHooks(idKey)))
{
stateHooks(idKey, init);
}
return [stateHooks(idKey), updateValue];
}
// effect hook
// add function to effects array
//
function useEffect(fun)
{
contextStore("effects", [...contextStore("effects"), fun]);
}
// create a key-value-store
// return accessor function
//
function createStore()
{
let data = {};
function access(key, ...value)
{
if (value.length === 0)
{
return data[key];
} else
{
data[key] = value[0];
return value[0];
}
}
return access;
}
/* =====================================================================
* Get and set focus and position in certain elements
* Note: this is a quick&dirty solution that probably fails when
* elements are added or removed
* =====================================================================
*/
// create a CSS selector for a given node
//
function getSelectorOf(node)
{
let selector = ""
while (node != document.body)
{
if (selector != "") selector = ">" + selector
if (node.id)
{
selector = "#" + node.id + selector
break
}
let index = Array.from(node.parentNode.children)
.filter(item => item.tagName == node.tagName)
.findIndex(item => item == node)
selector = node.tagName + ":nth-of-type(" + (index + 1) + ")" + selector
node = node.parentNode
}
return selector
}
// find a selector for the element that has focus and the cursor
// position in the element
//
function getFocusInput()
{
const active = document.activeElement;
let sel = active ? getSelectorOf(active) : undefined
let position = active ? active.selectionStart : undefined;
return [sel, position];
}
// set focus to an element in a list of elements matching a
// selector and position cursor in the element
//
function setFocusInput(selector, position)
{
if (selector && typeof (selector) == 'string')
{
console.log("Sel:" + selector)
let el = document.querySelector(selector);
if (el) el.focus();
if (el && "selectionStart" in el
&& "selectionEnd" in el
&& position !== undefined)
{
el.selectionStart = position;
el.selectionEnd = position;
}
}
}
/* =====================================================================
* Module export
* =====================================================================
*/
export { render, createElement, useState, useEffect };
/* =====================================================================
* EOF
* =====================================================================
*/

View file

@ -1,12 +0,0 @@
import { App } from "./Components.js";
import { render } from "./SuiWeb.js";
/**
* Initializes the board.
*/
function initialize()
{
render([App], document.querySelector("#game"));
}
initialize();

60
src/js/types.d.ts vendored
View file

@ -1,60 +0,0 @@
/**
* Represents the state of a field.
*/
type CellOwner = "" | "r" | "b";
/**
* Represents a row of the game field.
*/
type Row = CellOwner[];
/**
* Represents a game board.
*/
type Board = Row[];
/**
* Represents the state of a game.
*/
interface IState
{
/**
* The number of rounds that have been played.
*/
turnCount: number;
/**
* The board of the game.
*/
board: Board;
}
/**
* Represents a node in the SJDON notation.
*/
type NodeDescriptor =
TextDescriptor |
ElementDescriptor |
FunctionNode;
/**
* Represents a text-node in the SJDON notation.
*/
type TextDescriptor = string;
/**
* Represents an html-element in the SJDON notation.
*/
type ElementDescriptor = [
tag: string,
// eslint-disable-next-line @typescript-eslint/array-type
...args: (NodeDescriptor | (Partial<HTMLElement> | Record<string, any>))[]
];
/**
* Represents a component in the SJDON notation.
*/
type FunctionNode = [
fn: (...args: any[]) => NodeDescriptor,
...args: any[]
];

View file

@ -1,100 +0,0 @@
body {
font-family: Arial, Helvetica, sans-serif;
}
button {
padding: 0.5rem;
font-size: 1.3rem;
transition: 0.3s background-color ease-in-out;
background-color: azure;
border: lightblue solid 1px;
border-radius: 4px;
}
button:hover {
background-color: lightblue;
}
button:active {
background-color: rgb(96, 190, 221);
}
button:disabled {
background-color: whitesmoke;
border-color: lightgray;
}
div {
box-sizing: border-box;
}
.board {
width: 84vmin;
margin: 10px auto;
outline: 1px solid black;
}
.board .field {
border: 1px solid black;
width: 12vmin;
height: 12vmin;
display: flex;
align-items: center;
justify-content: center;
float: left;
}
.game.winner.r .board, .game.r .log {
background-color: pink;
}
.game.winner.b .board, .game.b .log {
background-color: lightblue;
}
.board .row .field:first-child {
clear: both;
}
.board .field .piece {
width: 95%;
height: 95%;
border-radius: 50%;
}
.board .field .blue {
background-color: blue;
}
.board .field .red {
background-color: red;
}
.log {
margin-top: 10px;
margin-bottom: 10px;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
text-align: center;
}
.game.winner .log {
font-size: 3rem;
}
.game .menu-bar {
text-align: center;
}
.game .menu-bar button {
margin: 0 5px 5px 0;
}
.ad {
margin-top: 3rem;
margin-bottom: 3rem;
}
.links {
text-align: center;
}

View file

@ -1,9 +0,0 @@
{
"extends": "@manuth/tsconfig/recommended",
"compilerOptions": {
"module": "Node16",
"lib": [
"ES2022"
]
}
}

View file

@ -1,15 +0,0 @@
{
"extends": "./tsconfig.base.json",
"references": [
{
"path": "./app.jsconfig.json"
},
{
"path": "./eslint.jsconfig.json"
},
{
"path": "./gulp.tsconfig.json"
}
],
"include": []
}