Compare commits
7 commits
Author | SHA1 | Date | |
---|---|---|---|
9444dddd49 | |||
40b069b632 | |||
a409ebb160 | |||
16453d44a7 | |||
e2f8bba503 | |||
4d45e8e576 | |||
07a33c8ee4 |
|
@ -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
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"spec": [
|
||||
"./lib/tests/main.test.js"
|
||||
],
|
||||
"ui": "tdd",
|
||||
"colors": true
|
||||
}
|
|
@ -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
|
@ -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/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
7
.vscode/settings.json
vendored
|
@ -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
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
2
LICENSE
|
@ -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:
|
||||
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"composite": true,
|
||||
"lib": [
|
||||
"DOM"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src/js/**/*"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 13 KiB |
|
@ -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.
|
Before Width: | Height: | Size: 11 KiB |
BIN
docs/Preview.png
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 11 KiB |
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"include": [
|
||||
"./gulpfile.ts",
|
||||
"./gulp/**/*"
|
||||
]
|
||||
}
|
143
gulp/Context.ts
|
@ -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);
|
||||
}
|
||||
}
|
202
gulpfile.ts
|
@ -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")));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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
50
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 565 B |
BIN
src/favicon.ico
Before Width: | Height: | Size: 176 KiB |
|
@ -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
|
@ -0,0 +1,5 @@
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default async (): Promise<void> =>
|
||||
{
|
||||
console.log("Hello World");
|
||||
};
|
|
@ -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;"
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
|
@ -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"
|
||||
};
|
||||
}
|
251
src/js/Game.js
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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("");
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
376
src/js/SuiWeb.js
|
@ -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
|
||||
* =====================================================================
|
||||
*/
|
|
@ -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
|
@ -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[]
|
||||
];
|
|
@ -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
|
@ -0,0 +1,13 @@
|
|||
import { assert } from "chai";
|
||||
|
||||
suite(
|
||||
"ConnectForce",
|
||||
() =>
|
||||
{
|
||||
test(
|
||||
"Example…",
|
||||
() =>
|
||||
{
|
||||
assert.equal(1, 1);
|
||||
});
|
||||
});
|
12
src/tests/tsconfig.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "../../lib",
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*"
|
||||
],
|
||||
"references": []
|
||||
}
|
14
tsconfig.app.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"./src/tests/**/*"
|
||||
]
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
{
|
||||
"extends": "@manuth/tsconfig/recommended",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"module": "Node16",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
]
|
||||
"ES2020"
|
||||
],
|
||||
"target": "ES6"
|
||||
}
|
||||
}
|
12
tsconfig.build.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./src/tests"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
@ -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;
|