Compare commits

..

7 commits

Author SHA1 Message Date
Manuel Thalmann 9444dddd49 Refactor sourcemap path fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-04 19:08:33 +01:00
Manuel Thalmann 40b069b632
Fix webpack source maps
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-03 15:20:07 +01:00
Manuel Thalmann a409ebb160 Compile mocha test files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-02 01:11:30 +01:00
Manuel Thalmann 16453d44a7 Replace unsupported module 2022-12-02 01:11:06 +01:00
Manuel Thalmann e2f8bba503 Add CI configuration
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-02 01:05:07 +01:00
Manuel Thalmann 4d45e8e576 Migrate to webpack 2022-12-02 00:58:56 +01:00
Manuel Thalmann 07a33c8ee4 Initialize a new project 2022-12-02 00:10:48 +01:00
41 changed files with 1644 additions and 7672 deletions

View file

@ -2,19 +2,18 @@ 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
es6: true
},
parserOptions: {
project: [
join(__dirname, "app.jsconfig.json"),
join(__dirname, "eslint.jsconfig.json"),
join(__dirname, "gulp.tsconfig.json")
join(__dirname, "tsconfig.app.json"),
join(__dirname, "misc.tsconfig.json"),
join(__dirname, "src", "tests", "tsconfig.json")
]
}
};

7
.mocharc.jsonc Normal file
View file

@ -0,0 +1,7 @@
{
"spec": [
"./lib/tests/main.test.js"
],
"ui": "tdd",
"colors": true
}

View file

@ -157,9 +157,8 @@ tsconfig.*.json
.drone.yml
.woodpecker.yml
# Build Environment
gulp/
gulpfile.ts
# Build System
webpack.config.ts
# Temporary release-assets
.tagName.txt

26
.vscode/launch.json vendored
View file

@ -1,19 +1,25 @@
// 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
{
// 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",
"type": "node",
"request": "launch",
"name": "Launch Website in Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/lib/static",
"name": "Launch Tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/mocha",
"args": [
"--timeout",
"0"
],
"internalConsoleOptions": "openOnSessionStart",
"preLaunchTask": "Build",
"pathMapping": {
"/": "${workspaceFolder}/src"
}
"sourceMaps": true,
"outFiles": [
"${workspaceFolder}/**/*.js",
"!**/node_modules/**"
]
}
]
}

View file

@ -5,9 +5,6 @@
"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"
"mochaExplorer.require": "source-map-support/register",
"mochaExplorer.timeout": 0
}

57
.vscode/tasks.json vendored
View file

@ -1,40 +1,17 @@
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
// 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"
],
"type": "npm",
"script": "watch",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [
{
"owner": "gulp",
"pattern": {
"regexp": ""
},
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Starting '(?!Watch).*?'"
},
"endsPattern": {
"regexp": "Finished '.*?'"
}
}
}
],
"problemMatcher": "$ts-webpack-watch",
"isBackground": true,
"presentation": {
"reveal": "never"
@ -42,31 +19,17 @@
},
{
"label": "Rebuild",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}"
},
"command": "npm",
"args": [
"run",
"rebuild"
],
"problemMatcher": [],
"type": "npm",
"script": "rebuild",
"problemMatcher": "$ts-webpack",
"presentation": {
"reveal": "never"
}
},
{
"label": "Lint",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}"
},
"command": "npm",
"args": [
"run",
"lint-ide"
],
"type": "npm",
"script": "lint-ide",
"problemMatcher": "$eslint-stylish",
"presentation": {
"reveal": "never"

View file

@ -11,19 +11,7 @@ pipeline:
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
test:
image: node
commands:
- npm run test

View file

@ -1,6 +1,6 @@
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:

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

View file

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

7280
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,28 +4,54 @@
"type": "module",
"description": "A selfmade Connect Four game.",
"author": "Manuel Thalmann <m@nuth.ch>",
"exports": {
".": {
"import": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
}
},
"./package.json": "./package.json"
},
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"gulp": "cross-env NODE_OPTIONS=\"--loader ts-node/esm\" gulp --",
"build": "npm run gulp Build",
"webpack": "cross-env NODE_OPTIONS=\"--loader ts-node/esm\" webpack",
"build": "npm run webpack --",
"compile": "tsc -b tsconfig.build.json",
"rebuild": "npm run clean && npm run build",
"watch": "npm run gulp Watch",
"clean": "rimraf ./lib",
"watch": "npm run build -- --watch",
"clean": "npm run compile -- --clean && rimraf ./lib",
"lint": "eslint --max-warnings 0 ./src .eslintrc.cjs",
"lint-ide": "npm run lint || exit 0",
"test": "mocha",
"prepare": "npm run rebuild"
},
"dependencies": {
"ts-keyof": "^1.3.0",
"ts-nameof-proxy": "^0.0.3"
},
"devDependencies": {
"@manuth/eslint-plugin-typescript": "^4.0.1",
"@manuth/eslint-plugin-typescript": "^4.0.0",
"@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",
"@types/chai": "^4.3.4",
"@types/mocha": "^9.1.1",
"@types/node": "^18.7.18",
"@types/webpack": "^5.28.0",
"chai": "^4.3.7",
"cross-env": "^7.0.3",
"eslint": "^8.29.0",
"gulp": "^4.0.2",
"eslint": "^8.23.1",
"mocha": "^10.0.0",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"upath": "^2.0.1"
"ts-patch": "^2.0.2",
"typescript": "^4.8.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0"
}
}

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>

5
src/index.ts Normal file
View file

@ -0,0 +1,5 @@
// eslint-disable-next-line import/no-default-export
export default async (): Promise<void> =>
{
console.log("Hello World");
};

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

13
src/tests/main.test.ts Normal file
View file

@ -0,0 +1,13 @@
import { assert } from "chai";
suite(
"ConnectForce",
() =>
{
test(
"Example…",
() =>
{
assert.equal(1, 1);
});
});

12
src/tests/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "..",
"outDir": "../../lib",
"composite": true
},
"include": [
"./**/*"
],
"references": []
}

14
tsconfig.app.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"outDir": "./lib"
},
"include": [
"./src/**/*"
],
"exclude": [
"./src/tests/**/*"
]
}

View file

@ -1,9 +1,11 @@
{
"extends": "@manuth/tsconfig/recommended",
"compilerOptions": {
"declaration": true,
"module": "Node16",
"lib": [
"ES2022"
]
"ES2020"
],
"target": "ES6"
}
}
}

12
tsconfig.build.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./src/tests"
}
]
}

View file

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

148
webpack.config.ts Normal file
View file

@ -0,0 +1,148 @@
import { writeFile } from "fs/promises";
import { dirname, isAbsolute, relative, resolve } from "path";
import { fileURLToPath } from "url";
import exports, { Configuration } from "webpack";
const { WatchIgnorePlugin } = exports;
let dirName = fileURLToPath(new URL(".", import.meta.url));
let generator = (env: any, argv: any): Configuration[] =>
{
return [
{
target: "web",
mode: env.production ? "production" : "development",
entry: {
main: "./src/index.ts",
...(
env.production ?
{} :
{
"tests/main.test": "./src/tests/main.test.ts"
})
},
output: {
filename: "[name].js",
path: resolve(dirName, "lib"),
devtoolModuleFilenameTemplate: (context: any) =>
{
let path = context.absoluteResourcePath;
// For regular files, this statement is true.
if (isAbsolute(path))
{
return path;
}
else
{
return `webpack://${context.namespace}/${context.resourcePath}${context.loaders === "" ? "" : `?${context.loaders}`}`;
}
},
libraryTarget: "module",
chunkFormat: "module",
environment: {
dynamicImport: true
}
},
devtool: "source-map",
resolve: {
extensions: [
".ts",
".js"
],
extensionAlias: {
".js": [
".js",
".ts"
],
".mjs": [
".mjs",
".mts"
],
".cjs": [
".cjs",
".cts"
]
}
},
plugins: [
new WatchIgnorePlugin(
{
paths: [
/\.d\.ts$/
]
}),
{
apply(compiler)
{
compiler.hooks.assetEmitted.tap(
{
name: "AdjustSourceMap"
},
async (file, { content, source, outputPath, compilation, targetPath }) =>
{
if (file.endsWith(".map"))
{
try
{
let sourceMap = JSON.parse(content.toString());
if (Array.isArray(sourceMap.sources))
{
sourceMap.sources = sourceMap.sources.map(
(source: string) =>
{
console.log(source);
// Prevent `webpack://` sources from being changed
if (isAbsolute(source))
{
// Change regular file paths to relative ones
return relative(dirname(targetPath), source);
}
else
{
return source;
}
});
// Overwrite old source map
await writeFile(targetPath, JSON.stringify(sourceMap));
}
}
catch {}
}
});
}
}
],
module: {
rules: [
{
test: /\.([cm]?ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: "ts-loader",
options: {
configFile: resolve(dirName, "tsconfig.build.json"),
projectReferences: true,
compilerOptions: {
outDir: resolve(dirName, "lib", "temp")
}
}
}
]
}
]
},
experiments: {
outputModule: true
}
}
];
};
// eslint-disable-next-line import/no-default-export
export default generator;