Compare commits

...

114 commits

Author SHA1 Message Date
Manuel Thalmann f8ae416fb4 Add a documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-23 20:24:25 +01:00
Manuel Thalmann 3a62c8902f Add a favicon 2022-12-23 18:28:42 +01:00
Manuel Thalmann b17363b9e8 Improve the look and feel of the log banner 2022-12-23 18:13:41 +01:00
Manuel Thalmann de0b81efac Add an ad banner 2022-12-23 18:13:25 +01:00
Manuel Thalmann c130b72ab6 Fix broken type declaration 2022-12-23 18:09:06 +01:00
Manuel Thalmann 191ae2113c Add style for disabled button
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-23 16:37:24 +01:00
Manuel Thalmann 7ba310c7ca Execute the publish task only on the main branch 2022-12-23 16:14:16 +01:00
Manuel Thalmann b1ace6ceb3
Improve the style of the game
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-23 16:01:06 +01:00
Manuel Thalmann 85d6298bff
Remove unnecessary console output 2022-12-21 10:32:19 +01:00
Manuel Thalmann 0ccffdf62e Force proper remote url
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 23:14:04 +01:00
Manuel Thalmann c11994c3a9 Add a step to the pipeline for publishing the website
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-15 23:08:48 +01:00
Manuel Thalmann da371fdca2 Change states using SuiWeb
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 22:42:57 +01:00
Manuel Thalmann c15f5f57f9 Allow null to be loaded
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 22:17:32 +01:00
Manuel Thalmann 608ec9c72a Fix broken component descriptor 2022-12-15 22:14:47 +01:00
Manuel Thalmann 5ce44a8d93 Add predefined suiweb 2022-12-15 22:09:02 +01:00
Manuel Thalmann 8f488f48bd Adjust SuiWeb according to the standards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 21:53:30 +01:00
Manuel Thalmann dedf7886e5 Nest function parameters into objects 2022-12-15 21:42:21 +01:00
Manuel Thalmann e5fddf7a7d Align the chips properly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 18:39:32 +01:00
Manuel Thalmann 194acdaea3 Make the board shrink based on the height 2022-12-15 18:39:11 +01:00
Manuel Thalmann a097fa2b88 Migrate from a workspace to a folder
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 18:25:30 +01:00
Manuel Thalmann aeeff83e01
Allow moves to be undone
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 18:16:08 +01:00
Manuel Thalmann 21d940d3ea
Remove unnecessary code 2022-12-15 14:33:42 +01:00
Manuel Thalmann c091e5c808
Minify code 2022-12-15 14:29:07 +01:00
Manuel Thalmann 023c725aae
Set class if someone wins
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 14:21:43 +01:00
Manuel Thalmann 30cbc40d38
Move all click handlers to the App 2022-12-15 14:17:11 +01:00
Manuel Thalmann 80b55732e7
Move all click events to Component.js 2022-12-15 13:47:23 +01:00
Manuel Thalmann 1e47864ee2
Remove obsolete files 2022-12-15 12:02:53 +01:00
Manuel Thalmann cd88607ad3
Add an event handler for the click event 2022-12-15 11:52:17 +01:00
Manuel Thalmann e6cea57d65
Store state as an object 2022-12-15 11:51:59 +01:00
Manuel Thalmann c94dc1cb8f
Refactor the ElementDescriptor type 2022-12-15 10:55:46 +01:00
Manuel Thalmann 5ff474a7ea
Nest render function in separate class
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 10:09:22 +01:00
Manuel Thalmann babdc6104e
Rename SJDON to SuiWeb 2022-12-15 10:07:48 +01:00
Manuel Thalmann 6beda8d14f
Render game using SJDON 2022-12-15 10:06:49 +01:00
Manuel Thalmann 7b765db827
Add currentPlayer property to Game class 2022-12-15 10:04:00 +01:00
Manuel Thalmann 8020d30d6b
Add a message if someone wins
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 01:16:28 +01:00
Manuel Thalmann 05c935d3fa
Only allow moves if there is no winner 2022-12-15 01:13:49 +01:00
Manuel Thalmann de2884c16d
Add a method for checking whether there is a winner 2022-12-15 01:13:18 +01:00
Manuel Thalmann 9107c557af
Store game in localStorage
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-15 01:02:20 +01:00
Manuel Thalmann e34ae6d1c3
Roll back to single workspace environment 2022-12-15 00:57:54 +01:00
Manuel Thalmann 6df42d2e3f
Remove express server 2022-12-15 00:53:33 +01:00
Manuel Thalmann d87da24d30
Replace template fields in license 2022-12-15 00:49:30 +01:00
Manuel Thalmann c40492c0e3
Fix broken type
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-15 00:43:35 +01:00
Manuel Thalmann 602d8b4d6f
Prevent new game button from reloading page 2022-12-15 00:43:27 +01:00
Manuel Thalmann 399fa7257c
Render game using SJDON 2022-12-15 00:43:15 +01:00
Manuel Thalmann 2aa9bf5b4b
Add function for rendering SJDON 2022-12-15 00:35:54 +01:00
Manuel Thalmann 2d0b47f603
Add a script for checking for a winner
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 22:01:08 +01:00
Manuel Thalmann fd172a48ed
Add code for saving and loading game
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 20:13:01 +01:00
Manuel Thalmann 63a56e5b11
Add missing response to PUT handler 2022-12-14 20:05:10 +01:00
Manuel Thalmann bc16d9607f
Fix format of GUIDs 2022-12-14 19:56:04 +01:00
Manuel Thalmann 01fea03112
Allow saving and restoring save games 2022-12-14 13:07:04 +01:00
Manuel Thalmann 7a28a7e314
Add express handlers for managing save games
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 11:23:04 +01:00
Manuel Thalmann f2a8b1e58e
Allow api access 2022-12-14 11:01:32 +01:00
Manuel Thalmann 07263baf6a
Serve ConnectForce using the server
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 10:23:53 +01:00
Manuel Thalmann c35136c6ae
Use the express server in the gulp task 2022-12-14 09:49:56 +01:00
Manuel Thalmann 38fb928ef7
Add ignorefiles 2022-12-14 09:36:51 +01:00
Manuel Thalmann 517b093b20
Hide unnecessary debug settings
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 09:30:46 +01:00
Manuel Thalmann 4e93da5488
Add debug config for launching project 2022-12-14 09:29:49 +01:00
Manuel Thalmann 077c2bd38a
Add debug settings for express 2022-12-14 09:28:41 +01:00
Manuel Thalmann 4447d7e15b
Remove unnecessary file 2022-12-14 09:27:57 +01:00
Manuel Thalmann 11436c3e5a
Refactor misleading script 2022-12-14 09:25:40 +01:00
Manuel Thalmann 95af0ea1fb
Enable auto-reload for express server 2022-12-14 09:19:38 +01:00
Manuel Thalmann 6b62422903
Update scripts for the use in workspace 2022-12-14 09:19:24 +01:00
Manuel Thalmann 7e2d7c0234
Add scripts for running the server 2022-12-14 09:07:40 +01:00
Manuel Thalmann ff27aa1cb3
Add an express server 2022-12-14 09:07:20 +01:00
Manuel Thalmann 02938810e0
Enable checking of all js files
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 08:25:47 +01:00
Manuel Thalmann fb8d516e96
Add project files for the server 2022-12-14 08:24:32 +01:00
Manuel Thalmann 52552e8fc2
Add ES2022 library by default 2022-12-14 08:20:52 +01:00
Manuel Thalmann 6429f05860
Add a server project
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-14 08:19:28 +01:00
Manuel Thalmann a64f91e7fa
Add workspace settings 2022-12-14 00:46:02 +01:00
Manuel Thalmann ce72afb8ed
Move website to separate subdirectory 2022-12-13 18:21:38 +01:00
Manuel Thalmann d88e3a7ac8
Add margin to board class
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-13 12:55:50 +01:00
Manuel Thalmann f0fb5a11fe
Add a log message 2022-12-13 12:55:39 +01:00
Manuel Thalmann 0794aaf8fb
Nest board in a separate element 2022-12-13 12:54:49 +01:00
Manuel Thalmann fb51f48a02
Add a method for adding chips 2022-12-13 11:28:59 +01:00
Manuel Thalmann 391edbed72
Adjust variable names for better understanding 2022-12-13 11:28:48 +01:00
Manuel Thalmann be4956f360
Allow dynamic creation of game states 2022-12-13 11:20:21 +01:00
Manuel Thalmann 87f078da13
Make board size dynamic 2022-12-13 11:18:23 +01:00
Manuel Thalmann 2cf011ed03
Store the board size as static variables 2022-12-13 11:12:06 +01:00
Manuel Thalmann 7950572ede
Add a button for resetting the game 2022-12-13 11:08:52 +01:00
Manuel Thalmann ba28926219
Add a method for resetting the game 2022-12-13 11:05:20 +01:00
Manuel Thalmann 63bfb5375e
Create a dedicated draw method 2022-12-13 11:04:17 +01:00
Manuel Thalmann 6faeae8b66
Store the id of the board in the Game class 2022-12-13 11:02:56 +01:00
Manuel Thalmann f542319b43
Move initialization code to Game class 2022-12-13 11:00:26 +01:00
Manuel Thalmann 5b46328d37
Store game state in Game class 2022-12-13 10:51:36 +01:00
Manuel Thalmann 8119440db0
Change indentation to spaces 2022-12-13 10:34:07 +01:00
Manuel Thalmann f3a9568f70 Store the current player in the State
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-11 22:55:03 +01:00
Manuel Thalmann aa1af7a9b8 Remove test components
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-10 00:36:43 +01:00
Manuel Thalmann b0d029e07f Fix style issues 2022-12-10 00:29:14 +01:00
Manuel Thalmann 9c9504363d
Repeatedly update game board
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-08 15:08:02 +01:00
Manuel Thalmann f36b60b2e4
Simplify accessability of player names 2022-12-08 15:07:50 +01:00
Manuel Thalmann 480a77ff86
Move state to a separate file 2022-12-08 14:32:09 +01:00
Manuel Thalmann 8269daa423
Remove test code 2022-12-08 13:31:04 +01:00
Manuel Thalmann 4717a6e4e9
Add code for automatically creating game board
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-07 22:24:48 +01:00
Manuel Thalmann 6e6eceab2b
Use strict equality checks 2022-12-07 22:19:07 +01:00
Manuel Thalmann 1c58ac04a4
Update all dependencies 2022-12-07 22:18:29 +01:00
Manuel Thalmann 427e704a61
Fix eslint configuration 2022-12-07 08:09:14 +01:00
Manuel Thalmann aaac8f1912
Fix design of Connect4 board
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-06 13:52:25 +01:00
Manuel Thalmann 0b5cffb9d9
Move styles to a separate file 2022-12-06 13:20:00 +01:00
Manuel Thalmann 6df728d36e
Fix browser path mapping
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-06 13:17:34 +01:00
Manuel Thalmann 7f61ac8d94
Add link to stylesheet 2022-12-06 13:17:21 +01:00
Manuel Thalmann cda33560ae Remove unnecessary import
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-05 18:48:19 +01:00
Manuel Thalmann e62ecce538 Add debug settings
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-12-05 18:47:12 +01:00
Manuel Thalmann 60b29d0893 Add template files 2022-12-05 18:47:05 +01:00
Manuel Thalmann 411c8b3741 Add all projects to the tsconfig.json file 2022-12-05 18:46:46 +01:00
Manuel Thalmann 5ae479fee7 Add tasks for building the website 2022-12-05 18:46:34 +01:00
Manuel Thalmann 43b36cb619 Fix format settings for html 2022-12-05 18:46:18 +01:00
Manuel Thalmann 8369fa4949 Fix console output of the Watch task 2022-12-05 18:45:55 +01:00
Manuel Thalmann 4d07cfac76 Add CI settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-04 21:51:34 +01:00
Manuel Thalmann cb9de981bb Add scripts for cleaning and rebuilding files 2022-12-04 21:50:50 +01:00
Manuel Thalmann cb8cd5c550 Allow passing arguments to gulp 2022-12-04 21:34:19 +01:00
Manuel Thalmann 003f32b6a4 Add a basic website 2022-12-04 21:33:38 +01:00
Manuel Thalmann 632091a0d8 Add capability of automatically reloading files 2022-12-04 21:26:31 +01:00
Manuel Thalmann 40144c1dd6 Add gulp for handling tasks 2022-12-04 20:47:18 +01:00
Manuel Thalmann 2d56c2e2e4 Initialize a new project 2022-12-04 19:32:52 +01:00
38 changed files with 11736 additions and 5 deletions

20
.eslintrc.cjs Normal file
View file

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

11
.gitignore vendored
View file

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

168
.npmignore Normal file
View file

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

8
.vscode/extensions.json vendored Normal file
View file

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

19
.vscode/launch.json vendored Normal file
View file

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

13
.vscode/settings.json vendored Normal file
View file

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

76
.vscode/tasks.json vendored Normal file
View file

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

29
.woodpecker.yml Normal file
View file

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

8
CHANGELOG.md Normal file
View file

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

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2022 Manuel Thalmann
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,3 +1,2 @@
# ConnectForce
A selfmade Connect Four game.
A selfmade Connect Four game.

14
app.jsconfig.json Normal file
View file

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

BIN
docs/DiagonalDown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/DiagonalUp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

143
docs/Documentation.md Normal file
View file

@ -0,0 +1,143 @@
---
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.
## 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.

BIN
docs/Horizontal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/Preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
docs/Vertical.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

10
eslint.jsconfig.json Normal file
View file

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

7
gulp.tsconfig.json Normal file
View file

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

143
gulp/Context.ts Normal file
View file

@ -0,0 +1,143 @@
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);
}
}

189
gulpfile.ts Normal file
View file

@ -0,0 +1,189 @@
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 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
])(
(error) =>
{
if (error)
{
reject(error);
}
else
{
resolve();
}
});
});
}
/**
* Reloads all browsers using `browser-sync`.
*
* @param syncer
* The browser-sync instance to work on.
*
* @param filePath
* A glob-path which points to the files which must be reloaded.
*
* @returns
* The actual task.
*/
function BrowserSync(syncer: BrowserSyncInstance, filePath?: string): TaskFunction
{
let BrowserSync: TaskFunction = (done) =>
{
if (filePath)
{
syncer.reload(filePath);
}
else
{
syncer.reload();
}
done();
};
return BrowserSync;
}
/**
* Builds and watches the files for changes.
*/
export let Watch: TaskFunction = async (): Promise<void> =>
{
return new Promise<void>(
(resolve, reject) =>
{
series(Build)(
(error) =>
{
if (error)
{
reject(error);
}
else
{
let syncer = browserSync.create();
syncer.init({
open: false,
server: join(context.StaticPath()),
online: false
});
watch(
context.SourcePath(context.JSDirName),
series(
JavaScript,
BrowserSync(syncer, "*.js")));
watch(
context.SourcePath(context.StyleDirName, "**", "*.css"),
series(
Styles,
BrowserSync(syncer, "*.css")));
watch(
context.SourcePath(context.AssetDirName),
series(
Assets,
BrowserSync(syncer, join(context.AssetDirName, "**", "*"))));
watch(
context.SourcePath("favicon.ico"),
series(
Icon,
BrowserSync(syncer, join("favicon.ico"))));
watch(
context.SourcePath("**", "*.html"),
series(
WebPages,
BrowserSync(syncer, "*.html")));
}
});
});
};

9733
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
package.json Normal file
View file

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

BIN
src/assets/ad.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
src/assets/adInfo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

13
src/index.html Normal file
View file

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

239
src/js/Components.js Normal file
View file

@ -0,0 +1,239 @@
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]
];
}
/**
* 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;"
}
]
]
]
];
}

15
src/js/Constants.js Normal file
View file

@ -0,0 +1,15 @@
/**
* 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 Normal file
View file

@ -0,0 +1,251 @@
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;
}
}

29
src/js/State.js Normal file
View file

@ -0,0 +1,29 @@
/**
* 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 Normal file
View file

@ -0,0 +1,376 @@
// @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
* =====================================================================
*/

12
src/js/main.js Normal file
View file

@ -0,0 +1,12 @@
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 Normal file
View file

@ -0,0 +1,60 @@
/**
* 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[]
];

95
src/styles/style.css Normal file
View file

@ -0,0 +1,95 @@
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;
}

9
tsconfig.base.json Normal file
View file

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

15
tsconfig.json Normal file
View file

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