feat: move to Astro and mostly reworked the Pokémon Shuffle game

Signed-off-by: Avior <github@avior.me>
This commit is contained in:
Florian Bouillon 2023-08-15 18:37:26 +02:00
parent 4bb1f17467
commit c42311eaae
111 changed files with 11290 additions and 10821 deletions

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS-specific files
.DS_Store
slicers/*
# Coverage
coverage/

View File

@ -6,8 +6,9 @@ indent_size = 4
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 120
[*.md] # Yaml Standard
[*.{yaml,yml}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
trim_trailing_whitespace = false

View File

@ -1,8 +0,0 @@
node_modules
out
.next
next-env.d.ts
*.js
__tests__

View File

@ -1,191 +0,0 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly"
},
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
ecmaFeatures: {
jsx: true
},
ecmaVersion: 2018,
sourceType: "module"
},
settings: {
react: {
version: "detect"
}
},
plugins: [
"react",
"@typescript-eslint"
],
rules: {
indent: [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
quotes: "off",
"@typescript-eslint/quotes": [
"error",
"single",
{ avoidEscape: true }
],
semi: "off",
"@typescript-eslint/semi": [
"error",
"never"
],
"no-unused-expressions": "off",
"@typescript-eslint/no-unused-expressions": ["error", { "allowTernary": true }],
"@typescript-eslint/adjacent-overload-signatures": "error",
"@typescript-eslint/array-type": [
"error",
{
default: 'generic'
}
],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
accessibility: "explicit"
}
],
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": [
"error",
{
multiline: {
delimiter: "none",
requireLast: true
},
singleline: {
delimiter: "comma",
requireLast: false
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"arrow-body-style": "error",
"arrow-parens": [
"error",
"always"
],
complexity: "off",
"constructor-super": "error",
curly: "error",
"dot-notation": "error",
"eol-last": "error",
eqeqeq: [
"error",
"smart"
],
"guard-for-in": "error",
"id-blacklist": [
"error",
"any",
"Number",
"number",
"String",
"string",
"Boolean",
"boolean",
"Undefined"
],
"id-match": "error",
"max-classes-per-file": [
"error",
1
],
"max-len": [
"warn",
{
code: 200
}
],
"new-parens": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-cond-assign": "error",
"no-debugger": "error",
"no-empty": "error",
"no-eval": "error",
"no-fallthrough": "off",
"no-invalid-this": "off",
"no-multiple-empty-lines": "error",
"no-new-wrappers": "error",
"no-shadow": [
"error",
{
hoist: "all"
}
],
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-underscore-dangle": "error",
"no-unsafe-finally": "error",
"no-unused-labels": "error",
"no-unused-vars": "off",
"no-var": "error",
"object-shorthand": "error",
"one-var": [
"error",
"never"
],
"prefer-const": "error",
"quote-props": [
"error",
"consistent-as-needed"
],
"radix": "error",
"space-before-function-paren": "off",
"@typescript-eslint/space-before-function-paren": ["error", {
asyncArrow: "always",
anonymous: "never",
named: "never"
}],
"spaced-comment": "error",
"use-isnan": "error",
"valid-typeof": "off",
}
};

1
.gitattributes vendored
View File

@ -1 +0,0 @@
* text=auto eol=lf

35
.github/workflows/build_and_check.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Build, check & Test
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node.js 20
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Prepare Tests
run: npm run install:test
- name: Test
run : npm run test

42
.gitignore vendored
View File

@ -1,29 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # build output
dist/
# generated types
.astro/
# dependencies # dependencies
/node_modules node_modules/
/.pnp
.pnp.js
# testing # logs
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
.env*
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
dist/ # environment variables
.env
.env.production
*~ # macOS-specific files
.DS_Store
slicers/*
# Coverage
coverage/
# Playwright
/playwright/

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,3 +1,8 @@
{ {
"typescript.tsdk": "node_modules\\typescript\\lib" "editor.quickSuggestions": {
"strings": "on"
},
"tailwindCSS.includeLanguages": {
"astro": "html"
}
} }

53
Dockerfile Normal file
View File

@ -0,0 +1,53 @@
# This Dockerfile allows you to run AstroJS in server mode
#########
# Build #
#########
FROM docker.io/node:20-alpine as BUILD_IMAGE
# run as non root user
USER node
# go to user repository
WORKDIR /home/node
# Add package json
ADD --chown=node:node package.json package-lock.json ./
# install dependencies from package lock
RUN npm ci
# Add project files
ADD --chown=node:node . .
# build
RUN npm run build
# remove dev deps
RUN npm prune --omit=dev
##############
# Production #
##############
FROM docker.io/node:20-alpine as PROD_IMAGE
# inform software to be in production
ENV NODE_ENV=production
ENV HOST=0.0.0.0
# run as non root user
USER node
# go to work folder
WORKDIR /home/node
# copy from build image
COPY --chown=node:node --from=BUILD_IMAGE /home/node/node_modules ./node_modules
COPY --chown=node:node --from=BUILD_IMAGE /home/node/dist ./dist
COPY --chown=node:node --from=BUILD_IMAGE /home/node/package.json /home/node/.env* ./
# Expose port
EXPOSE 3000
# run it !
CMD ["npm", "run", "start"]

38
Dockerfile.static Normal file
View File

@ -0,0 +1,38 @@
# This Dockerfile allows you to run Astro in a static container (no server side)
#########
# Build #
#########
FROM docker.io/node:20-alpine as BUILD_IMAGE
# run as non root user
USER node
# go to user repository
WORKDIR /home/node
# Add package json
ADD --chown=node:node package.json package-lock.json ./
# install dependencies from package lock
RUN npm ci
# Add project files
ADD --chown=node:node . .
# build
RUN npm run build
##############
# Production #
##############
FROM docker.io/nginx:1-alpine
# go to NGINX folder
WORKDIR /usr/share/nginx/html
# Copy the nginx config
ADD ./.docker/nginx.conf /etc/nginx/nginx.conf
# Copy dist fro mthe build image
COPY --from=BUILD_IMAGE /home/node/dist ./

View File

@ -1 +1,36 @@
# Avior Games # Fi3D Slicer as a Service
## API key
add `Authorization: Bearer {token}`
## API Endpoints
| endpoint | method | permission required | cookie access | api access | Description |
| :----------------------------: | :----: | :-----------------: | :-----------: | :--------: | :---------------------------------------: |
| /api/v1/users | GET | user.list | no | no | List every user accounts |
| /api/v1/users | POST | user.create | no | no | Create a new account |
| /api/v1/users/{userId} | GET | user.get | yes | yes | Get a user's informations |
| /api/v1/users/{userId} | PUT | user.set | yes | yes | Set a user's informations |
| /api/v1/users/{userId}/configs | GET | configs.get | yes | yes | get the list of the user's configurations |
| /api/v1/users/{userId}/configs | POST | configs.create | yes | yes | Add a new configuration to the user |
| /api/v1/users/{userId}/keys | GET | keys.get | yes | no | get the list of API key for the user |
| /api/v1/users/{userId}/keys | POST | keys.create | yes | no | create a new API key for the user |
| /api/v1/slice/{configId} | POST | slice.slice | yes | yes | run the main website action |
endpoints not available through API can still be accessed by admins with the `admin.` prefix to the permission
## API Key Permissions
### `slicing:*` permissions related to the slicing
| name | Description |
| :-----------: | :---------------------: |
| slicing:slice | Slice the specified STL |
### `configs:*` permissions related to the configuration files
| name | description |
| :------------: | :--------------------------------: |
| configs:create | Create a new configuration file |
| configs:get | Get an existing configuration file |

46
astro.config.mjs Normal file
View File

@ -0,0 +1,46 @@
import { defineConfig } from 'astro/config'
import tailwind from "@astrojs/tailwind"
import node from "@astrojs/node"
// const faviconHook = {
// name: 'Favicon',
// hooks: {
// "astro:build:setup": async () => {
// await Manifest.create('./src/assets/favicon.png', {
// name: 'Template'
// })
// }
// }
// }
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
compressHTML: true,
build: {
assets: 'assets',
inlineStylesheets: 'auto'
},
server: {
host: true
},
trailingSlash: 'never',
vite: {
server: {
watch: {
// support WSL strange things
usePolling: !!process.env.WSL_DISTRO_NAME
}
}
},
experimental: {
assets: true
},
// Customizable depending on goal
output: 'server',
adapter: node({
mode: "standalone"
}),
site: 'https://print.dzeio.com',
})

View File

@ -1,68 +0,0 @@
@startuml classes
!theme toy
class GameEngine {
- scenes: Record<string, Scene>
+ addScene(scene: Scene): void
+ setScene(id: string): void
+ removeScene(scene: string | Scene): void
+ start(): void
+ pause(): void
+ destroy(): void
}
class Scene {
+ id: string
+ setCamera(id: String): void
+ addComponents(components: ...Component2D): void
+ removeComponents(components: ...(Component2D | string)): void
}
GameEngine "1" -- "0-*" Scene : "contains"
class Component {
+ id: string
+ position: Vector2D
+ renderer: Renderer
+ position: Vector2D
+ scale: Vector2D
+ collider: Collider
+ origin: Vector2D
+ childs: Array<Component2D>
+ debug: boolean
+ init(): void
+ update(): void
}
Scene "1" -- "0-*" Component : "contains"
class Camera extends Component {
}
class Pointer extends Component {}
class Asset {}
class Sound {}
class Tileset {}
Component -- Component
Tileset -- Asset
class Vector2D {
+ x: number
+ y: number
}
class Collider {}
Component "1" -- "0-1" Collider
Component "1" -- "0-*" Vector2D
@enduml

View File

@ -1,117 +0,0 @@
// V1.1.2 - Moved webpack5 to top level
// Updated to commit from 2021-05-18
// https://github.com/vercel/next.js/commits/canary/packages/next/next-server/server/config-shared.ts
/**
* @type {import("next/dist/next-server/server/config-shared").NextConfig & import("next/dist/next-server/server/config-shared").defaultConfig}
*/
const nextConfig = {
// Experimentals
experimental: {
plugins: true,
profiling: process.env.NODE_ENV === 'developpment',
sprFlushToDisk: true,
workerThreads: true,
pageEnv: true,
optimizeImages: true,
optimizeCss: true,
scrollRestoration: true,
scriptLoader: true,
stats: process.env.NODE_ENV === 'developpment',
externalDir: true,
serialWebpackBuild: true,
conformance: true,
turboMode: true,
eslint: true,
// Bugged
// https://github.com/vercel/next.js/issues/18913
// reactRoot: true,
enableBlurryPlaceholder: true,
disableOptimizedLoading: false,
},
// Non experimental config
target: 'server',
poweredByHeader: false,
trailingSlash: false,
optimizeFonts: true,
reactStrictMode: true,
webpack5: true,
// Futures
future: {
strictPostcssConfiguration: true,
excludeDefaultMomentLocales: true
},
// Headers and rewrites
async headers() {
// CSS no CSP, x-xss-protection
const CSP = {
key: 'Content-Security-Policy',
value:
// default-src is set to self because prefetch-src is not working propelly see: https://bugs.chromium.org/p/chromium/issues/detail?id=801561
"default-src 'self'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"manifest-src 'self'; " +
"prefetch-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://proxy.dzeio.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src data: 'self'; " +
"font-src 'self'; " +
"connect-src 'self' https://proxy.dzeio.com; " +
"base-uri 'self';"
}
const XXssProtection = {
key: 'X-XSS-Protection',
value: '1; mode=block'
}
// JS no x-xss-protection
const headers = [{
key: 'X-Frame-Options',
value: 'DENY'
}, {
key: 'X-Content-Type-Options',
value: 'nosniff'
}, {
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
}, {
key: 'Permissions-Policy',
value: 'geolocation=(), microphone=(), interest-cohort=()'
}, {
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
}, {
key: 'X-Download-Options',
value: 'noopen'
}, {
key: 'Expect-CT',
value: 'max-age=86400, enforce'
}]
const excludedExtensions = ['js', 'css', 'json', 'ico', 'png']
.map((ext) => `(?!\\.${ext}$)`).join('|')
return [{
source: `/:path*((?!^\\/_next\\/image)|${excludedExtensions})`,
headers: [...headers, XXssProtection, CSP]
}, {
source: '/',
headers: [...headers, XXssProtection, CSP]
}, {
// No CSP, XXssProtection
source: `/:path*(\\.${excludedExtensions}$)`,
headers: headers
}, {
// No CSP, XXssProtection
source: '/_next/image',
headers: headers
}]
},
}
module.exports = nextConfig

5
e2e/README.md Normal file
View File

@ -0,0 +1,5 @@
# e2e
Hold End 2 End tests
currently WIP

8
e2e/example.spec.ts Normal file
View File

@ -0,0 +1,8 @@
import { expect, test } from '@playwright/test'
test('has title', async ({ page }) => {
await page.goto('/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Astro/);
})

2
next-env.d.ts vendored
View File

@ -1,2 +0,0 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

View File

@ -1,23 +0,0 @@
// Add support for Stylus/LESS
const preCSS = require('next-pre-css')
// Use Compose plugin for easier maintenance
const withPlugins = require('next-compose-plugins')
const {PHASE_DEVELOPMENT_SERVER} = require('next/constants')
const nextConfig = require('./dzeio.next.config')
module.exports = withPlugins([
[preCSS, {
cssModules: true,
cssLoaderOptions: {
localIdentName: "[hash:base64:6]",
},
[PHASE_DEVELOPMENT_SERVER]: {
cssLoaderOptions: {
localIdentName: "[path][name]__[local]"
}
}
}]
],
nextConfig
)

52
nginx.conf Normal file
View File

@ -0,0 +1,52 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 3000;
listen [::]:3000;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
include /etc/nginx/mime.types;
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
location / {
try_files $uri $uri.html /$uri /index.html;
}
# Plausible script
location /js/index.js {
# Change this if you use a different variant of the script
proxy_pass https://plausible.io/js/script.js;
proxy_set_header Host plausible.io;
# Tiny, negligible performance improvement. Very optional.
proxy_buffering on;
}
# Plausible script
location /api/event {
proxy_pass https://plausible.io/api/event;
proxy_set_header Host plausible.io;
proxy_buffering on;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
}

14959
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,36 @@
{ {
"name": "@avior/games", "name": "@fi3d/slicer-as-a-service",
"version": "1.1.0", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "astro dev",
"build": "next build", "start": "node ./dist/server/entry.mjs",
"server": "next start", "build": "astro build",
"lint": "eslint . --ext .ts,.tsx", "check": "npm run check:astro && npm run check:typescript",
"test": "jest --config jext.config.js" "check:astro": "astro check",
"check:typescript": "tsc --noEmit",
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "vitest --coverage --run",
"test:e2e": "playwright test",
"install:test": "playwright install --with-deps"
}, },
"dependencies": { "dependencies": {
"@dzeio/object-util": "^1.2.0", "@astrojs/node": "^5",
"critters": "^0.0.10", "@astrojs/tailwind": "^4",
"easy-sitemap": "^1.0.0", "@dzeio/listener": "^1.0.3",
"next": "^11.0.0", "@dzeio/logger": "^3",
"next-compose-plugins": "^2.2.0", "@dzeio/object-util": "^1",
"next-plausible": "^1.6.1", "@dzeio/url-manager": "^1",
"next-pre-css": "^1.0.0", "astro": "^2",
"react": "^17.0.2", "easy-sitemap": "^1.1.2",
"react-dom": "^17.0.2", "lucide-astro": "^0.262.0",
"react-feather": "^2.0.9", "stylus": "^0.59.0",
"stylus": "^0.54.7", "tailwindcss": "^3"
"stylus-loader": "^6.0.0",
"tslib": "^2.4.0",
"typescript": "^4.1.3",
"webpack": "^5.37.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.8.7", "@playwright/test": "^1.36.1",
"@types/favicons": "^6.2.0", "@types/node": "^20",
"@types/node": "^15.6.0", "@vitest/coverage-v8": "^0.33.0",
"@types/react": "^17.0.0", "vitest": "^0.33.0"
"@typescript-eslint/eslint-plugin": "^4.14.1",
"@typescript-eslint/parser": "^4.14.1",
"eslint": "^7.1.0",
"eslint-config-next": "^10.2.2",
"eslint-plugin-react": "^7.18.3",
"ts-node": "^9.1.1"
} }
} }

62
playwright.config.ts Normal file
View File

@ -0,0 +1,62 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI
},
outputDir: './playwright/results',
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined as any,
reporter: process.env.CI ? 'list' : [['html', {
outputFolder: './playwright/report',
open: 'never'
}]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
})

View File

@ -0,0 +1,23 @@
/* eslint-disable max-len */
import Collider from '.'
import type Vector2D from '../Vector2D'
export default class BoxCollider2D extends Collider<{
center?: Vector2D
scale?: Vector2D
}> {
/**
*
* @returns [Vector2D, Vector2D, Vector2D, Vector2D] the four points of the box collider
*/
// eslint-disable-next-line complexity
public pos(): [Vector2D, Vector2D, Vector2D, Vector2D] {
return [
this.component.calculatePositionFor('topLeft', true),
this.component.calculatePositionFor('topRight', true),
this.component.calculatePositionFor('bottomLeft', true),
this.component.calculatePositionFor('bottomRight', true),
]
}
}

View File

@ -0,0 +1,131 @@
import Collider from '.'
import GameEngine from '../..'
import ComponentRenderer from '../../Components/ComponentRenderer'
import CircleRenderer from '../../Renderer/CircleRenderer'
import MathUtils from '../../libs/MathUtils'
import Vector2D from '../Vector2D'
import BoxCollider2D from './BoxCollider2D'
import Circlecollider2D from './CircleCollider2D'
import PointCollider2D from './PointCollider2D'
export default class Checker {
public static boxCircleCollision(box: BoxCollider2D, circle: Circlecollider2D): boolean {
// clamp(value, min, max) - limits value to the range min..max
// Find the closest point to the circle within the rectangle
const [topLeft, bottomRight] = box.pos()
const center = circle.center()
const radius = circle.radius()
const closestX = MathUtils.clamp(center.x, topLeft.x, bottomRight.x)
const closestY = MathUtils.clamp(center.y, topLeft.y, bottomRight.y)
// Calculate the distance between the circle's center and this closest point
const distanceX = center.x - closestX
const distanceY = center.y - closestY
// If the distance is less than the circle's radius, an intersection occurs
const distanceSquared = distanceX * distanceX + distanceY * distanceY
return distanceSquared < radius * radius
}
public static circleCircleCollision(circle1: Circlecollider2D, circle2: Circlecollider2D) {
return false
}
/**
* check if a point collider with a rectangle (can handle rotated rectangles)
* @param pointCollider the point collider
* @param boxCollider the box collider
* @returns if the boxes collide
*/
public static pointBoxCollision(pointCollider: PointCollider2D, boxCollider: BoxCollider2D) {
const point = pointCollider.pos()
.rotate(boxCollider.component.getMiddle(true), -boxCollider.component.getAbsoluteRotation())
const [topLeftRotated, , , bottomRightRotated ] = boxCollider.pos()
const topLeft = topLeftRotated
.rotate(boxCollider.component.getMiddle(true), -boxCollider.component.getAbsoluteRotation())
const bottomRight = bottomRightRotated
.rotate(boxCollider.component.getMiddle(true), -boxCollider.component.getAbsoluteRotation())
return point
.isIn(topLeft, bottomRight)
}
/**
* dumb way to check currntly
*
* TODO: Handle rotation
*/
public static boxBoxCollision(box1: BoxCollider2D, box2: BoxCollider2D): boolean {
return this.rectangleRectangleCollisionV2(box1, box2)
}
public static posBoxBoxCollision(box1: [Vector2D, Vector2D], box2: [Vector2D, Vector2D]) {
return box1[1].x > box2[0].x && // self bottom higher than other top
box1[0].x < box2[1].x &&
box1[1].y > box2[0].y &&
box1[0].y < box2[1].y
}
// eslint-disable-next-line complexity
public static detectCollision(collider1: Collider, collider2: Collider, reverse = false): boolean {
if (collider1 instanceof BoxCollider2D && collider2 instanceof Circlecollider2D) {
return this.boxCircleCollision(collider1, collider2)
} else if (collider1 instanceof Circlecollider2D && collider2 instanceof Circlecollider2D) {
return this.circleCircleCollision(collider2, collider1)
} else if (collider1 instanceof BoxCollider2D && collider2 instanceof BoxCollider2D) {
return this.boxBoxCollision(collider2, collider1)
} else if (collider1 instanceof BoxCollider2D && collider2 instanceof PointCollider2D) {
return this.pointBoxCollision(collider2, collider1)
}
if (!reverse) {
return this.detectCollision(collider2, collider1, true)
}
return false
}
public static rectangleRectangleCollisionV2(box1: BoxCollider2D, box2: BoxCollider2D) {
const rect1 = box1.pos()
const rect2 = box2.pos()
for (let i = 0; i < 4; i++) {
const p1 = rect1[i]
const p2 = rect1[(i + 1) % 4]
for (let j = 0; j < 4; j++) {
const p3 = rect2[j]
const p4 = rect2[(j + 1) % 4]
if (this.lineLineCollision([p1, p2], [p3, p4])) {
return true
}
}
}
return false
}
/**
* I don't understand this shit (from god)
*/
// eslint-disable-next-line complexity
private static lineLineCollision([a, b]: [Vector2D, Vector2D], [c, d]: [Vector2D, Vector2D]) {
const den = (d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y)
const num1 = (d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)
const num2 = (b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)
if (den === 0) {
return num1 === 0 && num2 === 0
}
const r = num1 / den
const s = num2 / den
return r >= 0 && r <= 1 && s >= 0 && s <= 1
}
}

View File

@ -0,0 +1,16 @@
import Collider from '.'
import Vector2D from '../Vector2D'
export default class Circlecollider2D extends Collider<{
radius?: number
offset?: Vector2D
}> {
public center(): Vector2D {
return new Vector2D(0)
}
public radius(): number {
return this.params.radius ?? 0
}
}

View File

@ -0,0 +1,16 @@
import Collider from '.'
import Vector2D from '../Vector2D'
export default class PointCollider2D extends Collider<{
center?: Vector2D
scale?: Vector2D
}> {
/**
*
* @returns Vector2D the position of the point
*/
public pos(): Vector2D {
return this.component.getAbsolutePosition()
}
}

View File

@ -0,0 +1,35 @@
import type Component2D from '../../Component2D'
export interface BaseParams {
tags?: string | null | Array<string> | undefined
}
export default abstract class Collider<
// eslint-disable-next-line @typescript-eslint/ban-types
T extends {} | void = {} | void
> {
/**
* Colliders will only collide with othe that have the same type (undefined is a type)
*
* if type is null it will collide with everything
*/
public get tags() : string | null | Array<string> | undefined {
return this.params.tags
}
public set tags(value: string | null | Array<string> | undefined) {
this.params.tags = value
}
public readonly params: T & BaseParams = {} as T & BaseParams
public constructor(
public component: Component2D,
it: T & BaseParams | void
) {
if (it) {
this.params = it
}
}
}

View File

@ -1,4 +1,5 @@
import Component2D from 'GameEngine/Component2D' import GameEngine from '../..'
import type Component2D from '../../Component2D'
import Vector2D from '../Vector2D' import Vector2D from '../Vector2D'
type BuiltinCollisionTypes = 'click' | 'pointerDown' | 'pointerUp' type BuiltinCollisionTypes = 'click' | 'pointerDown' | 'pointerUp'
@ -22,7 +23,12 @@ export default class BoxCollider2D {
public pos(): [Vector2D, Vector2D] { public pos(): [Vector2D, Vector2D] {
const scale = this.scale.multiply(this.component.scale) const scale = this.scale.multiply(this.component.scale)
const positionCenter = this.component.origin.sub( const positionCenter = GameEngine.getGameEngine().currentScene?.position.sum(this.component.origin.sub(
new Vector2D(
this.component.position.x,
this.component.position.y
)
)) ?? this.component.origin.sub(
new Vector2D( new Vector2D(
this.component.position.x, this.component.position.x,
this.component.position.y this.component.position.y
@ -31,12 +37,22 @@ export default class BoxCollider2D {
const center = this.center.sum(positionCenter) const center = this.center.sum(positionCenter)
return [new Vector2D( return [new Vector2D(
center.x - scale.x / 2, center.x,
center.y - scale.y / 2 center.y
), ),
new Vector2D( new Vector2D(
center.x + scale.x / 2, center.x + scale.x,
center.y + scale.y / 2 center.y + scale.y
)] )]
} }
public collideWith(collider: BoxCollider2D) {
const selfPos = this.pos()
const otherPos = collider.pos()
return selfPos[1].x >= otherPos[0].x && // self bottom higher than other top
selfPos[0].x <= otherPos[1].x &&
selfPos[1].y >= otherPos[0].y &&
selfPos[0].y <= otherPos[1].y
}
} }

View File

@ -1,25 +1,42 @@
import Component2D, { ComponentState } from 'GameEngine/Component2D' import Component2D, { ComponentState } from '../../Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer' import ComponentRenderer from '../../Components/ComponentRenderer'
import BoxCollider2D from '../Collision/BoxCollider2D' import RectRenderer from '../../Renderer/RectRenderer'
import TextRenderer from '../../Renderer/TextRenderer'
import Vector2D from '../Vector2D' import Vector2D from '../Vector2D'
export default class ColliderDebugger extends Component2D { // TODO: rework it
public constructor(component: Component2D, collider: BoxCollider2D) { export default class ColliderDebugger extends Component2D<{collision?: Array<string>}> {
super()
this.collider = collider public readonly name = 'ColliderDebugger'
const [topLeft, bottomRight] = collider.pos()
const size = topLeft.sub(bottomRight) public override renderer: RectRenderer = new RectRenderer(this, {stroke: 'transparent'})
this.position = topLeft
this.scale = size private textRenderer!: TextRenderer
this.origin = new Vector2D(-(this.scale.x / 2), -(this.scale.y / 2))
this.renderer = new RectRenderer(this, {stroke: 'black'}) public override init() {
if (!this.parent) {
console.error('cant setup, no parent')
return
}
this.collider = this.parent.collider
this.position = new Vector2D(0)
this.scale = this.parent.scale
this.origin = this.parent.origin
const text = new ComponentRenderer()
this.textRenderer = new TextRenderer(text, {
color: 'black',
size: 1
})
text.updateParam('renderer', this.textRenderer)
this.childs.push(text)
} }
public update(state: ComponentState) { public override update(state: ComponentState) {
if (state.isColliding) { const len = state.collisions?.length ?? 0
(this.renderer as RectRenderer).material = 'rgba(0, 255, 0, .7)' this.renderer.setProps({
} else { material: len === 0 ? null : `rgba(0, 255, 0, .${len})`
(this.renderer as RectRenderer).material = undefined })
} this.textRenderer.setProps({text: len.toString()})
} }
} }

View File

@ -1,9 +1,9 @@
import Component2D from 'GameEngine/Component2D' import Component2D from '../../Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import Vector2D from '../Vector2D' import Vector2D from '../Vector2D'
import PointDebugger from './PointDebugger' import PointDebugger from './PointDebugger'
export default class ComponentDebug extends Component2D { export default class ComponentDebug extends Component2D {
public readonly name = 'ComponentDebug'
public constructor(component: Component2D) { public constructor(component: Component2D) {
super() super()
this.position = new Vector2D(0, 0) this.position = new Vector2D(0, 0)

View File

@ -1,14 +1,20 @@
import Component2D from 'GameEngine/Component2D' import Component2D from '../../Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer' import RectRenderer from '../../Renderer/RectRenderer'
import Vector2D from '../Vector2D' import Vector2D from '../Vector2D'
export default class PointDebugger extends Component2D { interface Props {
public constructor(point: Vector2D, color = 'red') { point: Vector2D
super() color?: string
this.scale = new Vector2D(1, 1) size?: number
this.position = point }
export default class PointDebugger extends Component2D<Props> {
public override zIndex?: number = 900
public override init() {
this.scale = new Vector2D(this.params.size ?? 1)
this.position = this.params.point
// console.log('Debugging point at location', point) // console.log('Debugging point at location', point)
// this.origin = component.origin // this.origin = component.origin
this.renderer = new RectRenderer(this, {material: color}) this.renderer = new RectRenderer(this, {material: this.params.color ?? 'red'})
} }
} }

View File

@ -1,9 +1,10 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import Component2D, { ComponentState } from 'GameEngine/Component2D' import Component2D from '../../Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer' import RectRenderer from '../../Renderer/RectRenderer'
import Vector2D from '../Vector2D' import Vector2D from '../Vector2D'
export default class TilingDebugger extends Component2D { export default class TilingDebugger extends Component2D {
public readonly name = 'TilingDebugger'
public constructor() { public constructor() {
super() super()
for (let i0 = 0; i0 < 10; i0++) { for (let i0 = 0; i0 < 10; i0++) {
@ -22,6 +23,7 @@ export default class TilingDebugger extends Component2D {
} }
class CaseDebugger extends Component2D { class CaseDebugger extends Component2D {
public readonly name = 'CaseDebugger'
public renderer: RectRenderer = new RectRenderer(this, {stroke: 'black'}) public renderer: RectRenderer = new RectRenderer(this, {stroke: 'black'})
public constructor(pos: Vector2D) { public constructor(pos: Vector2D) {
super() super()

View File

@ -1,9 +1,82 @@
export default class Vector2D { import GameEngine from '..'
public constructor( import MathUtils from '../libs/MathUtils'
public x: number,
public y: number
) {}
/* eslint-disable id-length */
export default class Vector2D {
public x: number
public y: number
// eslint-disable-next-line complexity
public constructor(
x: number | [number, number] | Vector2D | {x: number, y: number},
y?: number
) {
// init from a vector
if (x instanceof Vector2D) {
this.x = x.x
this.y = x.y
return
}
// init from an object with x & y as parameters
if (typeof x === 'object' && 'x' in x && 'y' in x && typeof x.x === 'number' && typeof x.y === 'number') {
this.x = x.x
this.y = x.y
return
}
// init from an array
if (Array.isArray(x) && x.length === 2 && typeof x[0] === 'number' && typeof x[1] === 'number') {
this.x = x[0]
this.y = x[1]
return
}
// handle x & y are numbers
if (typeof x === 'number') {
this.x = x
if (typeof y === 'number') {
this.y = y
} else {
this.y = x
}
return
}
// no init available
throw new Error(`can't create vector from input x: "${x}" (${typeof x}), y: "${y}" (${typeof y})`)
}
/**
* return the coordinates from the browser to a GE one
* @param clientX the x position from the client (excluding the X scroll)
* @param clientY the y position from the client (excluding the Y scroll)
* @returns the vector with the position in the game
*/
// eslint-disable-next-line complexity
public static fromBrowser(clientX: number, clientY: number) {
const ge = GameEngine.getGameEngine()
if (!ge) {
return new Vector2D(0)
}
return new Vector2D(
// get X position on browser
((clientX ?? 0) + window.scrollX - ge.canvas.offsetLeft) /
(ge.currentScene?.scale ?? 1) * ge.getXCaseCount() /
ge.canvas.offsetWidth + (ge.currentScene?.position?.x ?? 0),
// get Y position on browser
((clientY ?? 0) + window.scrollY - ge.canvas.offsetTop) /
(ge.currentScene?.scale ?? 1) * ge.getYCaseCount() /
ge.canvas.offsetHeight + (ge.currentScene?.position?.y ?? 0)
)
}
/**
* return a new vector multiplied with the current one
* @param v vector
* @returns a new vector
*/
public multiply(v: Vector2D): Vector2D { public multiply(v: Vector2D): Vector2D {
return new Vector2D( return new Vector2D(
v.x * this.x, v.x * this.x,
@ -11,17 +84,36 @@ export default class Vector2D {
) )
} }
public sum(v: Vector2D): Vector2D { /**
* return a new vector summed with the current one
* @param v vector or x to add
* @param y y to add
* @returns a new vector
*/
public sum(v: Vector2D | number, y?: number): Vector2D {
if (typeof v === 'number') {
return new Vector2D(this.x + v, this.y + (y ?? v))
}
return new Vector2D( return new Vector2D(
v.x + this.x, v.x + this.x,
v.y + this.y v.y + this.y
) )
} }
public sub(v: Vector2D): Vector2D { public neg(): Vector2D {
return new Vector2D( return new Vector2D(
v.x - this.x, -this.x,
v.y - this.y -this.y
)
}
public sub(x: Vector2D | number, y?: number): Vector2D {
if (typeof x === 'number') {
return new Vector2D(this.x - x, this.y - (y ?? x))
}
return new Vector2D(
x.x - this.x,
x.y - this.y
) )
} }
@ -46,8 +138,123 @@ export default class Vector2D {
) )
} }
public set(x: number, y: number) { /**
* return a new Vector with the this minus the other vector/x,y
*/
public minus(x: Vector2D | number, y?: number) {
return this.sum(
typeof x === 'number' ? -x : -x.x,
y? -y : typeof x === 'number' ? -x : -x.y,
)
}
public set(x: number | Vector2D, y?: number) {
if (typeof x === 'object') {
this.x = x.x
this.y = x.y
return this
}
this.x = x this.x = x
if (!y) {
this.y = x
} else {
this.y = y
}
return this
}
public setY(y: number) {
this.y = y this.y = y
return this
}
public setX(x: number) {
this.x = x
return this
}
public clone() {
return new Vector2D(this.x, this.y)
}
public toString() {
return `${this.x}, ${this.y}`
}
public toFixed(fractionDigits?: number) {
return `${this.x.toFixed(fractionDigits)}, ${this.y.toFixed(fractionDigits)}`
}
public equal(x: Vector2D | number, y?: number): boolean {
let otherX = 0
let otherY = y ?? 0
if (x instanceof Vector2D) {
otherX = x.x
otherY = x.y
} else {
otherX = x
if (!y) {
otherY = x
}
}
return otherX === this.x && otherY === this.y
}
public toArray(): [number, number] {
return [this.x, this.y]
}
/**
* return the angle to make [this] look at [point]
* @param point the second point
*
* @returns the angle in degrees
*/
public angle(point: Vector2D): number {
return Math.atan2(
this.y - point.y,
this.x - point.x
) * 180 / Math.PI + 180
}
// eslint-disable-next-line complexity
public toBrowser(): [number, number] {
const ge = GameEngine.getGameEngine()
const offsetLeft = ge.canvas.offsetLeft
const offsetTop = ge.canvas.offsetTop
const canvasW = ge.canvas.offsetWidth
const canvasH = ge.canvas.offsetHeight
const finalX = offsetLeft + canvasW * (this.x - ge.currentScene!.position.x) / ge.getXCaseCount() * ge.currentScene!.scale
const finalY = offsetTop + canvasH * (this.y - ge.currentScene!.position.y) / ge.getYCaseCount() * ge.currentScene!.scale
return [
finalX,
finalY
]
}
public distance(b: Vector2D): Vector2D {
return new Vector2D(
this.x - b.x,
this.y - b.y
)
}
/**
* rotate the vector by the {origin}
* @param origin the point of rotation
* @param degrees the number of degrees of rotation
*/
public rotate(origin: Vector2D, degrees: number): Vector2D {
const radians = MathUtils.toRadians(degrees)
const tmp = this.sum(-origin.x, -origin.y)
return new Vector2D(
origin.x + (tmp.x * Math.cos(radians) - tmp.y * Math.sin(radians)),
origin.y + (tmp.x * Math.sin(radians) + tmp.y * Math.cos(radians))
)
} }
} }

View File

@ -1,41 +1,82 @@
import Listener from '@dzeio/listener'
import Vector2D from './2D/Vector2D'
// eslint-disable-next-line no-shadow
export enum AssetStatus {
NOT_LOADED,
LOADING,
LOADED,
ERROR
}
/** /**
* Asset management Class * Asset management Class
*/ */
export default class Asset { export default class Asset extends Listener<{
loaded: (width: number, height: number) => void
error: (error?: string) => void
}> {
public static assets: Record<string, Asset> = {} public static assets: Record<string, Asset> = {}
public isLoaded = false public isLoaded = false
public status: AssetStatus = AssetStatus.NOT_LOADED
private image!: HTMLImageElement private image!: HTMLImageElement
private constructor( private constructor(
private path: string public readonly path: string
) {} ) {
super()
}
public static init(path: string) { public static init(path: string): Asset {
if (!this.assets[path]) { if (!this.assets[path]) {
this.assets[path] = new Asset(path) this.assets[path] = new Asset(path)
} }
return this.assets[path] return this.assets[path] as Asset
} }
public async load() { public async load() {
if (this.status === AssetStatus.LOADED || this.status === AssetStatus.LOADING) {
return
}
this.status = AssetStatus.LOADING
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
this.image = new Image() this.image = new Image()
this.image.src = this.path this.image.src = this.path
this.image.onload = () => { this.image.onload = () => {
// console.log('resource loaded', this.path, this.image.width, this.image.height)
if (this.image.width === 0 && this.image.height === 0) {
this.emit('error', 'sizeZero')
throw new Error(`resource (${this.path}) not correctly loaded!, width && height at 0`)
}
this.isLoaded = true this.isLoaded = true
this.status = AssetStatus.LOADED
this.emit('loaded', this.image.width, this.image.height)
res() res()
} }
this.image.onerror = rej this.image.onerror = () => {
console.error('Error loading image')
this.status = AssetStatus.ERROR
this.emit('error', 'defaultError')
rej(`resource (${this.path}) could not be loaded`)
}
}) })
} }
public async get() { public get() {
if (!this.isLoaded) { if (this.status !== AssetStatus.LOADED) {
await this.load() throw new Error(`Can't get (${this.path}) because it is not loaded, please load it before`)
} }
return this.image return this.image
} }
public size(): Vector2D {
if (this.status !== AssetStatus.LOADED) {
console.error(`Can't get (${this.path}) because it is not loaded, please load it before`)
return new Vector2D(0)
}
return new Vector2D(this.image.width, this.image.height)
}
} }

View File

@ -1,20 +1,82 @@
import BoxCollider2D from './2D/Collision/BoxCollider2D' /* eslint-disable complexity */
import { objectLoop } from '@dzeio/object-util'
import GameEngine from '.'
import Collider from './2D/Collider'
import Vector2D from './2D/Vector2D' import Vector2D from './2D/Vector2D'
import Renderer from './Renderer' import Renderer from './Renderer'
import Scene from './Scene'
/**
* Component base Props
*
* it contains internal elements that manage how the component works
*
* it's elements are kept between frames
*/
export interface ComponentProps {
// /**
// * Component unique ID
// */
// id: number
// /**
// * Component Renderer
// */
// renderer?: Renderer
zIndex?: number
// position?: Vector2D
// scale?: Vector2D
// collider?: Collider | Array<Collider>
// parent?: Component2D
// origin?: Vector2D
// childs?: Array<Component2D>
// debug?: boolean
// rotation?: number
// enabled?: boolean
scene?: Scene
initialized?: boolean
}
/**
* Component specific state
* change for each frames
*/
export interface ComponentState { export interface ComponentState {
mouseHovering: boolean collisions?: Array<{
collider: Collider
component: Component2D
tag: string | null | undefined
}>
/** /**
* is it is collinding return the type of collision * define if a collision check has been done
*/ */
isColliding?: string collisionChecked?: boolean
collisionCheckNeeded?: boolean
/**
* Temporary state containing the previous collision check position
*/
previousPosition?: Vector2D
}
/**
* Component internal states definition
*
* TODO: Verify cache interest
*/
interface ComponentCache {
absolutePosition?: {
previousPosition?: Vector2D
cacheResult?: Vector2D
previousRotation?: number
}
} }
export type StaticComponent< export type StaticComponent<
// eslint-disable-next-line @typescript-eslint/ban-types Props extends {} | void = {} | void,
T extends {} | void = {} | void Component extends Component2D<Props> = Component2D<Props>
> = > =
new (params: T | undefined) => Component2D<T> new (props: Props | undefined) => Component
/** /**
* 2D Component * 2D Component
@ -24,7 +86,25 @@ export default abstract class Component2D<
T extends {} | void = {} | void T extends {} | void = {} | void
> { > {
public params: T = {} as T private static components = 0
/**
* the component properties
*/
public readonly props: ComponentProps = {}
/**
* Component specific state
* change for each frames
*/
public readonly state: ComponentState = {}
/**
* unique number for the component
*
* maximum number of components is equal to Number.MAX_VALUE or 1.7976931348623157e+308
*/
public id: number = Component2D.components++
/** /**
* Indicate how the component is rendered * Indicate how the component is rendered
@ -32,13 +112,22 @@ T extends {} | void = {} | void
* @type {Renderer} * @type {Renderer}
* @memberof Component2D * @memberof Component2D
*/ */
public renderer?: Renderer public renderer?: Renderer | null
/**
* z Index to change the order of display
*
* (relative to parent if applicable)
*/
public zIndex?: number
/** /**
* Component position relative to the parent position and to the component origin * Component position relative to the parent position and to the component origin
* *
* (see also: Component2D.origin) * (see also: Component2D.origin)
* *
* (relative to parent's position if applicatable)
*
* @type {Vector2D} * @type {Vector2D}
* @memberof Component2D * @memberof Component2D
*/ */
@ -47,7 +136,7 @@ T extends {} | void = {} | void
/** /**
* Component scale relative to 1 case size * Component scale relative to 1 case size
* *
* (see also: GameEngine.caseSize) * (relative to parent's position if applicatable)
* *
* @type {Vector2D} * @type {Vector2D}
* @memberof Component2D * @memberof Component2D
@ -57,13 +146,22 @@ T extends {} | void = {} | void
/** /**
* Component collider for events * Component collider for events
* *
* @type {BoxCollider2D} * @type {Collider}
* @memberof Component2D * @memberof Component2D
*/ */
public collider?: BoxCollider2D public collider: Collider | Array<Collider> | null = null
/** /**
* Change the origin point (default to middle) * Parent component of self is any
* this value is automatically set
*
* @type {Component2D}
* @memberof Component2D
*/
public parent?: Component2D
/**
* Change the origin point (default to top left)
*/ */
public origin: Vector2D = new Vector2D(0 , 0) public origin: Vector2D = new Vector2D(0 , 0)
@ -86,12 +184,45 @@ T extends {} | void = {} | void
*/ */
public debug?: boolean public debug?: boolean
/**
* Component rotation in Degrees
*/
public rotation = 0
/**
* define if the component is enabled (update nor render is run if false)
*/
public enabled?: boolean = true
protected params: T = {} as T
private cache: ComponentCache = {}
public constructor(it: T | void) { public constructor(it: T | void) {
if (it) { if (it) {
this.params = it this.params = it
} }
} }
/**
* Change a property of the component
*
* @param newProps the new props for the component
*/
public setProps(newProps: Partial<ComponentProps>) {
objectLoop(newProps, (value, obj) => {
if (obj === 'zIndex') {
this.zIndex = value as number
}
if (this.props[obj] === value) {
return
}
this.props[obj] = value as any
})
return this
}
/** /**
* Function run when the component is initialized * Function run when the component is initialized
*/ */
@ -103,5 +234,167 @@ T extends {} | void = {} | void
*/ */
public update?(state: ComponentState): Promise<void> | void public update?(state: ComponentState): Promise<void> | void
public destroy?(): Promise<void> | void /**
* run just before the component is going to be destroyed
*/
public destroy?(): void
/**
* get the element absolute position depending on it's rotation, origin and parent's absolute position
*/
public getAbsolutePosition(calculateRotation = true): Vector2D {
let pos = this.position.sum(
this.scale.multiply(this.origin)
)
if (this.parent) {
pos = pos.sum(this.parent.getAbsolutePosition(calculateRotation))
}
const rotation = this.getAbsoluteRotation()
if (this.cache.absolutePosition?.previousPosition && this.cache.absolutePosition.previousPosition.equal(pos) && this.cache.absolutePosition.previousRotation === rotation) {
return this.cache.absolutePosition.cacheResult as Vector2D
}
if (this.rotation && calculateRotation) {
const res = pos.rotate(this.getMiddle(true), this.getAbsoluteRotation())
// FIXME: should not modify the component position here
pos.set(res)
this.cache.absolutePosition = {
cacheResult: res,
previousPosition: pos,
previousRotation: rotation
}
}
return pos
}
/**
* get the component's absolute rotation
* @returns the component's absolute rotation in degrees
*/
public getAbsoluteRotation(): number {
if (this.parent) {
return (this.parent.getAbsoluteRotation() + this.rotation) % 360
}
return (this.rotation + (GameEngine.getGameEngine().currentScene?.globalRotation ?? 0)) % 360
}
public getAbsoluteZIndex(): number {
const zIndex = this.zIndex ?? 0
if (this.parent) {
return this.parent.getAbsoluteZIndex() + zIndex
}
return zIndex
}
public setState<K extends keyof ComponentState>(key: K, value: ComponentState[K]): void
public setState(key: keyof ComponentState, value: any): void {
this.state[key as keyof ComponentState] = value
}
public updateParam<K extends keyof T>(key: K, value: Component2D<T>['params'][K]): void {
if (this.params[key] !== value) {
this.params[key] = value
this.onParamUpdated(key as string, value)
}
}
public getParam<K extends keyof T>(key: K): Component2D<T>['params'][K]
public getParam(key: string): any
public getParam<K extends keyof T>(key: K | string): K | any {
return this.params[key as keyof T]
}
public getParams(): Component2D<T>['params'] {
return this.params
}
/**
* return the real width of the element (including rotation and all the shit)
*/
public getCalculatedWidth(): number {
const radians = this.rotation * (Math.PI / 180)
const rotatedWidth = Math.abs(this.scale.x * Math.cos(radians)) + Math.abs(this.scale.y * Math.sin(radians))
return rotatedWidth
}
/**
* return the real width of the element (including rotation and all the shit)
*/
public getCalculatedHeight(): number {
const radians = this.rotation * (Math.PI / 180)
const rotatedWidth = Math.abs(this.scale.y * Math.cos(radians)) + Math.abs(this.scale.x * Math.sin(radians))
return rotatedWidth
}
/**
* get the most top left position of the element
*
* if rotated it will give a point out of the element
*
* @param absolute give the relative or absolute position
* @returns the point in space in a relative or absolute position
*/
public calculateTopLeftPosition(absolute = false): Vector2D {
return this.getMiddle(absolute).sub(
this.getCalculatedWidth() / 2,
this.getCalculatedHeight() / 2
)
}
/**
* get the most bottom right position of the element
*
* if rotated it will give a point out of the element
*
* @param absolute give the relative or absolute position
* @returns the point in space in a relative or absolute position
*/
public calculateBottomRightPosition(absolute = false): Vector2D {
return this.getMiddle(absolute).sum(
this.getCalculatedWidth() / 2,
this.getCalculatedHeight() / 2
)
}
public calculatePositionFor(item: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight', absolute = false) {
let pos = this.position
if (absolute && this.parent) {
pos = this.getAbsolutePosition(false)
}
switch (item) {
case 'topRight':
pos = pos.sum(this.scale.x, 0)
break
case 'bottomLeft':
pos = pos.sum(0, this.scale.y)
break
case 'bottomRight':
pos = pos.sum(this.scale.x, this.scale.y)
break
}
return pos.rotate(this.getMiddle(absolute), this.getAbsoluteRotation())
}
/**
* return the center point of the component
* @param absolute return the absolute position instead of the local position
*
* @returns the location of the middle of the component
*/
public getMiddle(absolute = false): Vector2D {
if (absolute) {
return this.getAbsolutePosition(false).sum(this.scale.div(2))
}
return this.position.sum(this.scale.div(2))
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected onParamUpdated(key: string, value: any) {}
} }

View File

@ -1,8 +1,161 @@
import GameEngine from '..'
import Vector2D from '../2D/Vector2D' import Vector2D from '../2D/Vector2D'
import Component2D from '../Component2D'
import TextRenderer from '../Renderer/TextRenderer'
import { apply } from '../libs/CodeUtils'
import MathUtils from '../libs/MathUtils'
import Cursor from './Cursor'
/** export default class Camera extends Component2D<{
* Currently not working Camera implementation position?: Vector2D
*/ zoom?: number
export default class Camera { debug?: boolean
public topLeft = new Vector2D(0.5, 0.5) minX?: number
maxX?: number
minY?: number
maxY?: number
minZoom?: number
maxZoom?: number
disableZoom?: boolean
childs?: Array<Component2D>
}> {
public name = 'Camera'
// public position: Vector2D = new Vector2D(0)
private _zoom = this.params.zoom ?? 1
public get zoom() {
return this._zoom
}
public set zoom(value) {
if (value === this.params.zoom && this.params.disableZoom) {
this._zoom = value
}
if (this.params.disableZoom) {
return
}
const old = this._zoom
this._zoom = MathUtils.clamp(value, this.params.minZoom ?? 0.01, this.params.maxZoom ?? Infinity)
const ge = GameEngine.getGameEngine()
this.scale = new Vector2D(
ge.getXCaseCount() / this.zoom,
ge.getYCaseCount() / this.zoom
)
this.onZoomChange(old, this._zoom)
}
public getScale(): Vector2D {
const ge = GameEngine.getGameEngine()
this.scale = new Vector2D(
ge.getXCaseCount() / this.zoom,
ge.getYCaseCount() / this.zoom
)
return this.scale
}
public init(): void | Promise<void> {
if (this.params.position) {
this.position = this.params.position
}
if (this.params.zoom) {
this.setZoom(this.params.zoom)
}
if (this.debug) {
this.renderer = new TextRenderer(this, {
color: 'black',
text: '',
size: 16
})
}
if (this.params.childs) {
this.childs.push(
...this.params.childs
)
}
}
// eslint-disable-next-line complexity
public update() {
this.position.set(
MathUtils.clamp(this.position.x, this.params.minX ?? -Infinity, this.params.maxX ?? Infinity),
MathUtils.clamp(this.position.y, this.params.minY ?? -Infinity, this.params.maxY ?? Infinity)
)
let needCursorUpdate = false
const scene = GameEngine.getGameEngine()?.currentScene
if (!scene) {
return
}
if (scene.scale !== this.zoom) {
scene.scale = this.zoom
needCursorUpdate = true
}
if (!scene.position.equal(this.position)) {
scene.position.set(this.position)
needCursorUpdate = true
}
if (needCursorUpdate) {
const cursor = scene.getComponents().find((it) => it instanceof Cursor) as Cursor | undefined
cursor?.triggerUpdate()
}
if (this.debug) {
apply(this.renderer as TextRenderer, (it) => {
it.setProps({
text: `pos: ${this.position.toFixed(3)}, scale: ${this.getScale().toFixed(3)}, zoom: ${this.zoom}`,
size: 16 / this._zoom
})
})
}
}
public setZoomAndPos(zoom: number, x: number | Vector2D, y?: number) {
this._zoom = zoom
this.position.set(x, y)
}
/**
*
* @param value zoom with 1 being the base
*/
public setZoom(value: number) {
this.zoom = value
}
public addToZoom(value: number, min?: number, max?: number) {
this.zoom += value
if (min && min > this.zoom) {
this.zoom = min
}
if (max && max < this.zoom) {
this.zoom = max
}
}
// eslint-disable-next-line complexity
private onZoomChange(oldZoom: number, newZoom: number) {
if (oldZoom === newZoom) {
return
}
const ge = GameEngine.getGameEngine()
const cursor = ge.currentScene?.getComponents().find((it) => it instanceof Cursor) as Cursor | undefined
const amount = 1 - oldZoom / newZoom
const scale = this.getScale()
let at: Vector2D
if (cursor) {
at = cursor.position
} else {
at = this.position.sum(scale.div(2))
}
const newX = this.position.x + (at.x - this.position.x) * amount
const newY = this.position.y + (at.y - this.position.y) * amount
this.position.set(
MathUtils.clamp(newX, this.params.minX ?? -Infinity, this.params.maxX ?? Infinity),
MathUtils.clamp(newY, this.params.minY ?? -Infinity, this.params.maxY ?? Infinity)
)
}
} }

View File

@ -0,0 +1,54 @@
import BoxCollider2D from '../2D/Collider/BoxCollider2D'
import Vector2D from '../2D/Vector2D'
import Component2D, { ComponentState } from '../Component2D'
import Renderer from '../Renderer'
import { apply } from '../libs/CodeUtils'
import Cursor from './Cursor'
export default class ComponentRenderer extends Component2D<{
renderer?: Renderer
position?: Vector2D
scale?: Vector2D
onClick?: () => void
onDown?: () => void
onUp?: () => void
collisionTag?: string | Array<string>
}> {
public name = 'ComponentRenderer'
// eslint-disable-next-line complexity
public update(states: ComponentState): void | Promise<void> {
if (this.params.onClick && !this.collider) {
this.collider = apply(new BoxCollider2D(this, {scale: new Vector2D(2)}), (it) => it.tags = this.params.collisionTag)
} else if (this.params.onClick || this.params.onDown) {
const collision = states.collisions?.find((it) => it.component instanceof Cursor)
const cursor = collision?.component as Cursor
if (cursor && !cursor.isDown && cursor.wasDown) {
this.params.onClick?.()
this.params.onUp?.()
} else if (cursor && cursor.isDown && !cursor.wasDown) {
this.params.onDown?.()
}
}
}
public updateRenderer(key: string, value: any) {
this.renderer?.setProps({[key]: value})
}
protected onParamUpdated(key: string, value: any): void {
if (this.params.renderer) {
this.renderer = this.params.renderer
}
if (this.params.position) {
this.position = this.params.position
}
if (this.params.scale) {
this.scale = this.params.scale
}
}
}

View File

@ -0,0 +1,372 @@
import GameEngine from '..'
import PointCollider2D from '../2D/Collider/PointCollider2D'
import Vector2D from '../2D/Vector2D'
import Component2D from '../Component2D'
import MultiRenderer from '../Renderer/MultiRenderer'
import RectRenderer from '../Renderer/RectRenderer'
import TextRenderer from '../Renderer/TextRenderer'
import { apply } from '../libs/CodeUtils'
import Camera from './Camera'
type MoveEvents = 'leftClickDown' | 'middleClickDown'
interface ButtonState {
/**
* left button is down (refreshed each frames)
*/
isDown: boolean
/**
* left button was down previous frame (refreshed each frames)
*/
wasDown: boolean
/**
* left button is down in realtime
*/
eventDown: boolean
}
export default class Cursor extends Component2D<{
debug?: boolean
disableZoom?: boolean
workOverInterface?: boolean
/**
* allow the cursor to move the view
*/
enabledMove?: boolean | MoveEvents | Array<MoveEvents>
zIndex?: number
}> {
public name = 'Cursor'
/**
* cursor position
*/
public override position: Vector2D = new Vector2D(0,0)
public leftBtn = {
isDown: false,
wasDown: false,
eventDown: false,
canChange: true
}
public rightBtn = {
isDown: false,
wasDown: false,
eventDown: false,
canChange: true
}
public middleBtn = {
isDown: false,
wasDown: false,
eventDown: false,
canChange: true
}
public override origin: Vector2D = new Vector2D(0)
public override scale: Vector2D = new Vector2D(1)
public override collider: PointCollider2D = new PointCollider2D(this, {
tags: 'cursor'
})
private touchZoom = 0
private oldPosition: [number, number] | null = null
private base!: Window | Element
/**
* is the cursor click is down
*/
public get isDown(): boolean {
return this.leftBtn.isDown || this.rightBtn.isDown || this.middleBtn.isDown
}
/**
* was the cursor click down on previous frame
*/
public get wasDown(): boolean {
return this.leftBtn.wasDown || this.rightBtn.wasDown || this.middleBtn.wasDown
}
public override init(): void | Promise<void> {
this.base = window ?? GameEngine.getGameEngine().canvas
this.base.addEventListener('mousemove', this.onMouseMove)
this.base.addEventListener('mousedown', this.onMouseDown)
this.base.addEventListener('touchmove', this.onTouchMove, { passive: false })
this.base.addEventListener('touchstart', this.onTouchStart)
if (this.params.zIndex) {
this.zIndex = this.params.zIndex
}
// add up events on document so they are catched everywhere
window.addEventListener('mouseup', this.onMouseUp)
window.addEventListener('touchend', this.onTouchEnd)
// this.debug = this.params?.debug
if (this.params.debug) {
this.renderer = new MultiRenderer(this, {
renderers: [
new RectRenderer(this, {material: 'blue'}),
new TextRenderer(this, {
color: 'black',
text: '',
size: 16,
overrideSizeLimit: true
})
]
})
}
}
public override destroy(): void {
this.base.removeEventListener('mousemove', this.onMouseMove as any)
this.base.removeEventListener('mousedown', this.onMouseDown as any)
this.base.removeEventListener('touchmove', this.onTouchMove as any)
this.base.removeEventListener('touchstart', this.onTouchStart as any)
// add up events on document so they are catched everywhere
window.removeEventListener('mouseup', this.onMouseUp)
window.removeEventListener('touchend', this.onTouchEnd)
}
public override update(): void | Promise<void> {
this.leftBtn.wasDown = this.leftBtn.isDown
this.leftBtn.isDown = this.leftBtn.eventDown
this.leftBtn.canChange = true
this.rightBtn.wasDown = this.rightBtn.isDown
this.rightBtn.isDown = this.rightBtn.eventDown
this.rightBtn.canChange = true
this.middleBtn.wasDown = this.middleBtn.isDown
this.middleBtn.isDown = this.middleBtn.eventDown
this.middleBtn.canChange = true
if (this.params.debug) {
console.log('update')
;((this.renderer as MultiRenderer).props.renderers?.[1] as TextRenderer).setProps({text: `${JSON.stringify(this.leftBtn, undefined, '\t')}\npos: ${this.position.toFixed(3)}`})
}
}
public triggerUpdate() {
this.updatePosition(...this.oldPosition ?? [0, 0])
}
public setPosition(clientX: number, clientY: number) {
this.updatePosition(clientX, clientY)
}
public getPosition() {
return this.position
}
private onMouseMove = (ev: MouseEvent) => {
if (this.params.debug) {
console.log('onMouseMove')
}
this.onMove(ev)
}
// eslint-disable-next-line complexity
private onMouseDown = (ev: MouseEvent) => {
if (this.params.debug) {
console.log('onMouseDown')
}
switch (ev.button) {
case 0:
this.leftBtn.eventDown = true
this.leftBtn.canChange = false
break
case 2:
this.rightBtn.eventDown = true
this.rightBtn.canChange = false
break
case 1:
this.middleBtn.eventDown = true
this.middleBtn.canChange = false
break
default:
console.warn('WTF is this mouse button')
break
}
this.onDown(ev)
}
// eslint-disable-next-line complexity
private onMouseUp = (ev: MouseEvent) => {
if (this.params.debug) {
console.log('onMouseUp')
}
switch (ev.button) {
case 0:
this.leftBtn.eventDown = false
this.leftBtn.canChange = false
break
case 2:
if (!this.rightBtn.canChange) {
break
}
this.rightBtn.eventDown = false
this.rightBtn.canChange = false
break
case 1:
if (!this.middleBtn.canChange) {
break
}
this.middleBtn.eventDown = false
this.middleBtn.canChange = false
break
default:
console.warn('WTF is this mouse button')
break
}
this.onUp(ev)
}
// eslint-disable-next-line complexity
private onTouchMove = (ev: TouchEvent) => {
if (this.params.debug) {
console.log('onTouchMove')
}
this.leftBtn.eventDown = true
if (ev.touches.length >= 2 && !this.params.disableZoom) {
ev.preventDefault()
// launch the onMove event with the pointer position being at the center of both points
const xMin = Math.min(ev.touches[0].pageX, ev.touches[1].pageX)
const xMax = Math.max(ev.touches[0].pageX, ev.touches[1].pageX)
const yMin = Math.min(ev.touches[0].pageY, ev.touches[1].pageY)
const yMax = Math.max(ev.touches[0].pageY, ev.touches[1].pageY)
this.onMove([
xMin + (xMax - xMin) / 2,
yMin + (yMax - yMin) / 2
])
const cam = GameEngine
.getGameEngine()
.currentScene
?.camera
if (!cam) {
return
}
const nv = Math.hypot(
ev.touches[0].pageX - ev.touches[1].pageX,
ev.touches[0].pageY - ev.touches[1].pageY
)
cam.addToZoom(-((this.touchZoom - nv) / 100), 1)
this.touchZoom = nv
return
}
this.onMove(ev.touches.item(0) ?? undefined)
}
private onTouchStart = (ev: TouchEvent) => {
if (this.params.debug) {
console.log('onTouchStart')
}
this.leftBtn.eventDown = true
this.onDown(ev.touches.item(0) ?? undefined)
if (ev.touches.length >= 2) {
this.touchZoom = Math.hypot(
ev.touches[0].pageX - ev.touches[1].pageX,
ev.touches[0].pageY - ev.touches[1].pageY
)
}
}
private onTouchEnd = (ev: TouchEvent) => {
if (this.params.debug) {
console.log('onTouchEnd')
}
this.leftBtn.eventDown = false
this.onUp(ev.touches.item(0) ?? undefined)
}
/**
* Catch the onMove events
*/
private onMove(ev?: MouseEvent | Touch | [number, number]) {
if (Array.isArray(ev)) {
this.updatePosition(
ev[0],
ev[1]
)
return
}
if (ev) {
// console.log('onMove')
this.updatePosition(
ev.clientX ?? 0,
ev.clientY ?? 0
)
}
}
/**
* Catch the onDown events
*/
// eslint-disable-next-line complexity
private onDown(ev?: MouseEvent | Touch) {
if (ev) {
if ((ev.target as HTMLElement).nodeName !== 'CANVAS' && !this.params.workOverInterface) {
return
}
// console.log('onDown')
this.updatePosition(
ev.clientX ?? 0,
ev.clientY ?? 0
)
}
}
/**
* catch the onUp events
*/
private onUp(ev?: MouseEvent | Touch) {
// console.log('onUp')
if (ev) {
this.updatePosition(
ev.clientX ?? 0,
ev.clientY ?? 0
)
}
this.oldPosition = null
}
// eslint-disable-next-line complexity
private updatePosition(clientX: number, clientY: number) {
const ge = GameEngine.getGameEngine()
if (!ge) {
return
}
const moveEvents: Array<MoveEvents> =
this.params.enabledMove === true && ['leftClickDown', 'middleClickDown'] ||
typeof this.params.enabledMove === 'string' && [this.params.enabledMove] ||
(Array.isArray(this.params.enabledMove) ? this.params.enabledMove : [])
let doMove = false
for (const event of moveEvents) {
if (event === 'leftClickDown' && this.leftBtn.isDown) {
doMove = true
break
}
if (event === 'middleClickDown' && this.middleBtn.isDown) {
doMove = true
break
}
}
if (doMove && this.oldPosition) {
const camera = ge.currentScene?.components.find((it) => it instanceof Camera) as Camera | undefined
if (camera) {
const diff = [this.oldPosition[0] - clientX, this.oldPosition[1] - clientY]
camera.position = camera.position.sum(diff[0] / 5 / camera.zoom, diff[1] / 5 / camera.zoom)
}
}
this.oldPosition = [clientX, clientY]
this.position.set(Vector2D.fromBrowser(clientX, clientY))
}
}

View File

@ -1,57 +1,29 @@
import GameEngine from 'GameEngine' import GameEngine from '..'
import ComponentDebug from 'GameEngine/2D/Debug/ComponentDebug' import Vector2D from '../2D/Vector2D'
import Vector2D from 'GameEngine/2D/Vector2D' import Component2D from '../Component2D'
import Component2D from 'GameEngine/Component2D' import TextRenderer from '../Renderer/TextRenderer'
import TextRenderer from 'GameEngine/Renderer/TextRenderer'
export default class FPSCounter extends Component2D<{textColor?: string, size?: number}> { export default class FPSCounter extends Component2D<{size?: number}> {
public name = 'FPSCounter'
public override position: Vector2D = new Vector2D(0)
public override scale: Vector2D = new Vector2D(100, 1)
public override renderer: TextRenderer = new TextRenderer(this, {text: 'loading...', color: 'black', stroke: 'white'})
public position: Vector2D = new Vector2D(10,8) public override init() {
public scale: Vector2D = new Vector2D(1, 1)
public origin: Vector2D = new Vector2D(0, 0)
public childs: Array<Component2D> = [new ComponentDebug(this)]
public renderer: TextRenderer = new TextRenderer(this, {text: 'loading...'})
private length = 1
private previousFrameTimes: Array<number> = []
private lastUpdate = window.performance.now() * 1000
public init() {
const fps = GameEngine.getGameEngine().options?.goalFramerate
if (!fps || fps < 1) {
this.length = 60
} else {
this.length = fps
}
if (this.params.textColor) {
this.renderer.color = this.params.textColor
}
if (this.params.size) { if (this.params.size) {
this.renderer.size = this.params.size this.renderer.setProps({size: this.params.size})
} }
} }
public update() { public override update() {
const t = GameEngine.getGameEngine().lastFrame const perfs = GameEngine.getGameEngine().currentScene?.updatePerformances
// if (!t) {return} if (!perfs || perfs.total === -1) {
// console.log(this.previousFrameTimes, t) this.renderer.setProps({text: 'Loading...'})
const diff = t - this.lastUpdate return
this.lastUpdate = t
this.previousFrameTimes.push(diff)
if (this.previousFrameTimes.length > this.length) {
this.previousFrameTimes.shift()
}
const time = (this.previousFrameTimes.reduce((p, c) => p + c, 0)) / this.previousFrameTimes.length
if (time === 0) {
this.renderer.text = 'a lot'
} else {
this.renderer.text = (1000 / time).toFixed(2)
} }
this.renderer.setProps({text: (1000 / (perfs.total ?? 1)).toFixed(0)})
} }
} }

View File

@ -0,0 +1,470 @@
import { NotificationManager } from '@dzeio/components'
import Listener from '@dzeio/listener'
export interface ControllerConnectionEvent {
controller: ControllerInterface
}
/**
* Event containing a button state change
*/
export interface ControllerButtonEvent {
/**
* The key as a text
*
* can be the keyboard/gamepad character
*/
key: string
/**
* Warning: prefer the use of 'key'
*/
keyId: number
/**
* Say if the button is pressed or not
*/
pressed: boolean
/**
* Reference to the controller
*/
controller: ControllerInterface
}
export interface ControllerAxisEvent {
/**
* axis id that changed
*/
axis: string
/**
* axis new value as a value between 0 and 1
*
* @see Controller.setControllerDeadZone to setup the controller dead zone globally
*/
value: number
/**
* Reference to the controller
*/
controller: ControllerInterface
}
export interface ControllerStates {
/**
* timestamp of new the input changed value last time
*/
lastChange: number
/**
* if the key being repeated
*
* mostly for internal usage
*/
repeat?: boolean
/**
* the current value of the input
*/
value: number
}
interface GamepadControllerInterface {
/**
* The Gamepad ID (mostly the name/brand with some unique identifier somewhere in the text)
*/
id: string
/**
* the type of controller
*
* can be 'gamepad' | 'keyboard'
*/
type: 'gamepad'
/**
* the Browser gamepad class
*/
gamepad: Gamepad
/**
* the gamepad mapping
*/
mapping: GamepadMapping
/**
* the input states
*/
states: Record<string | number, ControllerStates>
}
type ControllerInterface = {
/**
* the type of controller
*
* can be 'gamepad' | 'keyboard'
*/
type: 'keyboard'
/**
* The Gamepad ID (mostly the name/brand with some unique identifier somewhere in the text)
*/
id: 'keyboard'
} | GamepadControllerInterface
/**
* Gamepad mapping of IDs into human readable keys
*/
type GamepadMapping = Array<string | null>
/**
* Nintendo Switch specific controls
*/
const SwitchMapping: GamepadMapping = [
'b',
'a',
'x',
'y',
'screen',
'l',
'r',
'zl',
'zr',
'select',
'start',
'home',
'leftthumb',
'rightthumb'
]
/**
* Xbox specific controls
*/
const XboxMapping: GamepadMapping = [
'a',
'b',
null,
'x',
'y',
null,
'lb',
'rb',
null,
null,
null,
'start',
null,
'leftthumb',
'rightthumb'
]
/**
* Default mapping made as a base
*
* to add a new mapping use this url
* https://luser.github.io/gamepadtest/
*/
const DefaultMapping: GamepadMapping = [
'a',
'b',
'y',
'x',
'l',
'r',
'zl',
'zr',
'select',
'start'
]
/**
* This class allows you to get the controller states and update
* your application using the different Controllers like a keyboard or a Gamepad
*
* Please use `Controller.destroy()` at the end of your
* usage to finish the event listeners setup by the class
*/
export default class Controller extends Listener<{
/**
* event sent when a new connection is established
*/
connected: (ev: ControllerConnectionEvent) => void
/**
* event sent when a connection is broken
*/
disconnected: (ev: ControllerConnectionEvent) => void
/**
* event sent when the key is down
*/
keyDown: (ev: ControllerButtonEvent) => void
/**
* event sent once the key is up
*/
keyUp: (ev: ControllerButtonEvent) => void
/**
* Event sent when a key is pressed
*/
keyPress: (ev: ControllerButtonEvent) => void
/**
* Event sent when an axe is moving
*
* Event is sent continiously until it goes back to 0
*
* @see Controller.setControllerDeadZone to setup the controller dead zone globally
*/
axisMove: (ev: ControllerAxisEvent) => void
all: (eventName: string, ...args: Array<any>) => void
}> {
/**
* List of external gamepads
*/
private gamepads: Array<ControllerInterface> = []
/**
* Gamepad key/axes loop
*/
private doLoop = false
/**
* Controller axes dead zone
*/
private controllerDeadZone = 0.5
public constructor() {
super()
// Add the gamepad event listeners
window.addEventListener('gamepadconnected', this.onGamepadConnect)
window.addEventListener('gamepaddisconnected', this.onGamepadDisconnect)
// add the keyboard event listeners
document.addEventListener('keydown', this.keyboardKeyDownEvent)
document.addEventListener('keyup', this.keyboardKeyPressEvent)
document.addEventListener('keypress', this.keyboardKeyUpEvent)
}
/**
* set the controller (Gamepads only) axis dead zone
* @param value value between 0 and 1
*/
public setControllerDeadZone(value: number) {
if (value < 0 || value >= 1) {
throw new Error(`Controller Dead Zone Out of bound (must respect 0 < value (${value}) < 1)`)
}
this.controllerDeadZone = value
}
/**
* terminate the class
*/
public destroy() {
this.doLoop = false
this.gamepads = []
window.removeEventListener('gamepadconnected', this.onGamepadConnect)
window.removeEventListener('gamepaddisconnected', this.onGamepadDisconnect)
document.removeEventListener('keydown', this.keyboardKeyDownEvent)
document.removeEventListener('keyup', this.keyboardKeyPressEvent)
document.removeEventListener('keypress', this.keyboardKeyUpEvent)
}
/**
* Browser keyboard event handler
*/
private keyboardKeyDownEvent = (ev: KeyboardEvent) => {
this.emit('keyDown', {
key: ev.key,
keyId: ev.keyCode,
pressed: true,
controller: {
type: 'keyboard',
id: 'keyboard'
}
})
}
/**
* Browser keyboard event handler
*/
private keyboardKeyPressEvent = (ev: KeyboardEvent) => {
this.emit('keyPress', {
key: ev.key,
keyId: ev.keyCode,
pressed: true,
controller: {
type: 'keyboard',
id: 'keyboard'
}
})
}
/**
* Browser keyboard event handler
*/
private keyboardKeyUpEvent = (ev: KeyboardEvent) => {
this.emit('keyUp', {
key: ev.key,
keyId: ev.keyCode,
pressed: false,
controller: {
type: 'keyboard',
id: 'keyboard'
}
})
}
/**
* Handle gamepad disconnection
*/
private onGamepadDisconnect = (ev: GamepadEvent) => {
const index = this.gamepads.findIndex((it) => it.id === ev.gamepad.id)
if (index < 0) {
return
}
const gamepad = this.gamepads.splice(index, 1)[0]
console.log('Controller disconnected', gamepad.id)
if (this.gamepads.length === 0) {
this.doLoop = false
}
this.emit('disconnected', {
controller: gamepad
})
}
/**
* Handle gamepad connection
*/
private onGamepadConnect = (ev: GamepadEvent) => {
const gp = ev.gamepad
if (!gp) {
NotificationManager.addNotification('Gamepad connected but not usable by device')
return
}
// create it's interface
const gamepad: ControllerInterface = {
type: 'gamepad',
id: gp.id,
gamepad: gp,
mapping: gp.id.includes('Switch') ? SwitchMapping : gp.id.includes('Xbox') ? XboxMapping : DefaultMapping,
states: {}
}
// add buttons to states
for (let idx = 0; idx < gp.buttons.length; idx++) {
const button = gp.buttons[idx]
gamepad.states['button-' + idx] = {
lastChange: new Date().getTime(),
value: button.pressed ? 1 : 0
}
}
// add axis to states
for (let idx = 0; idx < gp.axes.length; idx++) {
const axe = gp.axes[idx]
gamepad.states['axe-' + idx] = {
lastChange: new Date().getTime(),
value: axe
}
}
console.log('New Controller connected', gamepad.id)
// add it to the global gamepads list
this.gamepads.push(gamepad)
this.emit('connected', {
controller: gamepad
})
// start gamepads polling for new states
if (!this.doLoop) {
this.doLoop = true
this.update()
}
}
/**
* Polling to check if the gamepad has changes with it's buttons or not
*/
private update() {
const now = new Date().getTime()
// loop through every gamepads
for (let gIdx = 0; gIdx < this.gamepads.length; gIdx++) {
const gamepad = this.gamepads[gIdx]
if (gamepad.type !== 'gamepad') {
continue
}
// Chromium specific as gamepad.gamepad is not updated
gamepad.gamepad = navigator.getGamepads()[gIdx] ?? gamepad.gamepad
// loop through each buttons
for (let idx = 0; idx < gamepad.gamepad.buttons.length; idx++) {
const button = gamepad.gamepad.buttons[idx]
const gs = gamepad.states['button-' + idx]
const repeatedPress = gs.repeat || gs.lastChange + 300 < now
// handle state change or press repetition
if (button.pressed !== !!gs.value || button.pressed && repeatedPress) {
if (button.pressed && gs.value && repeatedPress) {
gs.repeat = true
} else if (!button.pressed) {
gs.repeat = false
}
// send keypress event once
if (button.pressed && !gs.value) {
this.emit('keyPress', {
key: gamepad.mapping[idx] ?? idx.toString(),
keyId: idx,
pressed: button.pressed,
controller: gamepad
})
}
// send keydown/keyup event
gs.lastChange = now
gamepad.states['button-' + idx].value = button.pressed ? 1 : 0
this.emit(button.pressed ? 'keyDown' : 'keyUp', {
key: gamepad.mapping[idx] ?? idx.toString(),
keyId: idx,
pressed: button.pressed,
controller: gamepad
})
}
}
// loop through each axises
for (let idx = 0; idx < gamepad.gamepad.axes.length; idx++) {
let axe = gamepad.gamepad.axes[idx]
if (Math.abs(axe) < this.controllerDeadZone) {
axe = 0
}
// emit event when value is not a 0
if (axe !== gamepad.states['axe-' + idx].value || axe !== 0) {
gamepad.states['axe-' + idx].value = axe
this.emit('axisMove', {
axis: idx.toString(),
value: axe,
controller: gamepad
})
}
}
}
// ask for new loop when available
if (this.doLoop) {
requestAnimationFrame(() => this.update())
}
}
}

View File

@ -1,31 +0,0 @@
import Event from '.'
export default class PointerEvents extends Event {
public override init(): void {
document.addEventListener('mousemove', this.basicEvent)
document.addEventListener('mousedown', this.mouseDown)
document.addEventListener('mouseup', this.mouseUp)
}
public update() {
// pouet
}
public override destroy() {
document.removeEventListener('mousemove', this.basicEvent)
document.removeEventListener('mousedown', this.mouseDown)
document.removeEventListener('mouseup', this.mouseUp)
}
private basicEvent = (ev: MouseEvent) => {
console.log('Mouse Event :D')
}
private mouseUp = (ev: MouseEvent) => {
this.basicEvent(ev)
}
private mouseDown = (ev: MouseEvent) => {
this.basicEvent(ev)
}
}

View File

@ -1,11 +0,0 @@
import GameEngine from 'GameEngine'
export default abstract class Event {
public constructor(
protected ge: GameEngine
) {}
abstract init(): void
abstract update(): void
abstract destroy(): void
}

View File

@ -0,0 +1,61 @@
/* eslint-disable max-depth */
import Renderer from '.'
import type GameEngine from '..'
interface Params {
material?: string
stroke?: string | {
color: string
width: number
dotted?: number
position?: 'inside' | 'center' | 'outside'
}
}
export default class CircleRenderer extends Renderer<Params> {
// eslint-disable-next-line complexity
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
await super.render(ge, ctx)
const item = this.preRender(ctx, ge)
ctx.beginPath()
const realX = item[0] + item[2] / 2
const realY = item[1] + item[3] / 2
ctx.arc(realX, realY, item[2] / 2, 0, 180 * Math.PI)
if (this.props.material) {
ctx.fillStyle = this.props.material
ctx.fill()
}
if (this.props.stroke) {
if (typeof this.props.stroke === 'string') {
ctx.strokeStyle = this.props.stroke
ctx.stroke()
} else {
if (this.props.stroke.dotted) {
ctx.setLineDash([this.props.stroke.dotted / 2, this.props.stroke.dotted])
}
ctx.lineWidth = this.props.stroke.width * (ge.currentScene?.scale ?? 1)
ctx.stroke()
if (this.props.stroke.dotted) {
ctx.setLineDash([])
}
}
}
if (this.debug) {
if (typeof this.props.stroke === 'string') {
ctx.strokeStyle = this.props.stroke
} else {
ctx.strokeStyle = 'red'
ctx.lineWidth = 1 * (ge.currentScene?.scale ?? 1)
}
ctx.strokeRect(...item)
}
this.postRender(ctx, ge)
}
}

View File

@ -0,0 +1,80 @@
import Renderer from '.'
import GameEngine from '..'
import Asset, { AssetStatus } from '../Asset'
interface Params {
asset?: Asset
stream?: boolean
imageRotation?: number
debug?: boolean
/**
* padding for each sides
*/
padding?: number
}
/**
* TODO: Add origin support
*/
export default class ImageRenderer extends Renderer<Params> {
protected defaultProps: Partial<Params> = {
stream: true
}
private padding = 0
public onUpdate(): void {
const ge = GameEngine.getGameEngine()
this.padding = (this.props.padding ?? 0) * ge.caseSize.x * (ge.currentScene?.scale ?? 1)
}
// eslint-disable-next-line complexity
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
await super.render(ge, ctx)
if (!this.props.asset) {
return
}
if (this.props.asset.status !== AssetStatus.LOADED) {
if (this.props.stream) {
// load asset but do not stop threads
this.props.asset.load()
return
} else {
await this.props.asset.load()
}
}
if (this.props.asset.status === AssetStatus.LOADING && this.props.stream) {
return
}
const padding = this.padding
const size = this.props.asset.size()
const final = this.preRender(ctx, ge, this.props.imageRotation)
const x = final[0] + (padding ?? 0)
const y = final[1] + (padding ?? 0)
const width = Math.max(final[2] - (padding ?? 0) * 2, 0)
const height = Math.max(final[3] - (padding ?? 0) * 2, 0)
if (this.debug || this.component.debug) {
ctx.fillStyle = 'red'
ctx.fillRect(...final)
}
ctx.drawImage(
this.props.asset.get(),
0,
0,
size.x,
size.y,
x,
y,
width,
height
)
this.postRender(ctx, ge)
}
}

View File

@ -0,0 +1,16 @@
import Renderer from '.'
import type GameEngine from '..'
interface Params {
renderers: Array<Renderer>
}
export default class MultiRenderer extends Renderer<Params> {
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
await super.render(ge, ctx)
for await (const renderer of this.props.renderers) {
await renderer.render(ge, ctx)
}
}
}

View File

@ -1,57 +1,292 @@
import { objectLoop } from '@dzeio/object-util' /* eslint-disable complexity */
import GameEngine from 'GameEngine' /* eslint-disable max-len */
import Asset from 'GameEngine/Asset' /* eslint-disable max-depth */
import Component2D from 'GameEngine/Component2D' import { objectKeys, objectLoop } from '@dzeio/object-util'
import Renderer from '.' import Renderer from '.'
import GameEngine from '..'
interface Params { export interface StrokeOptions {
material?: string | Asset color: string
stroke?: string | {color: string, width: number} width: number
dotted?: number
position?: 'inside' | 'center' | 'outside'
offset?: number
} }
export default class RectRenderer extends Renderer implements Params { // type Border = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
// type Stroke = 'top' | 'bottom' | 'left' | 'right'
public material?: string | Asset interface Params {
public stroke?: string | {color: string, width: number} material?: string | null
alpha?: number
stroke?: string | StrokeOptions | {
top?: StrokeOptions
right?: StrokeOptions
bottom?: StrokeOptions
left?: StrokeOptions
}
borderRadius?: number | {
topLeft?: number
topRight?: number
bottomLeft?: number
bottomRight?: number
}
debug?: boolean
}
public constructor(component: Component2D, params?: Params) { export default class RectRenderer extends Renderer<Params> {
super(component)
objectLoop(params ?? {}, (v, k) => {this[k as 'material'] = v}) private borderRadius: {
topLeft: number
topRight: number
bottomLeft: number
bottomRight: number
} = {
topLeft: 0,
topRight: 0,
bottomLeft: 0,
bottomRight: 0
}
private stroke?: {
top?: StrokeOptions
right?: StrokeOptions
bottom?: StrokeOptions
left?: StrokeOptions
} }
// eslint-disable-next-line complexity
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
const position = this.getPosition() await super.render(ge, ctx)
const item: [number, number, number, number] = [ const item = this.preRender(ctx, ge)
// source x const scaling = ge.currentScene?.scale ?? 1
// 0 - 1.5 - -1.5
position.x * (ge.caseSize.x),
// source y
position.y * (ge.caseSize.y),
// source end X
this.component.scale.x * (ge.caseSize.x),
// source end Y
this.component.scale.y * (ge.caseSize.y)
]
if (this.material instanceof Asset) { ctx.globalAlpha = this.props.alpha ?? 1
ctx.drawImage(
await this.material.get(), if (this.props.material) {
...item ctx.fillStyle = this.props.material
) // ctx.fillRect(...item)
return this.roundRect(ctx, ...item, true, false)
} }
if (this.material) { if (this.stroke && objectKeys(this.stroke).length > 0) {
ctx.fillStyle = this.material
ctx.fillRect(...item) objectLoop(this.stroke, (options, strokePos) => {
if (!options) {
return
}
let offset = options.width * scaling / 2
if (options.position === 'inside') {
offset = -offset
} else if (options.position === 'center') {
offset = 0
}
ctx.lineWidth = options.width * scaling
const lineByTwo = ctx.lineWidth / 3
ctx.strokeStyle = options.color
const xStart = item[0] - offset
const xEnd = item[0] + item[2] + offset
const yStart = item[1] - offset
const yEnd = item[1] + item[3] + offset
if (options.dotted) {
ctx.setLineDash([options.dotted / 2 * scaling, options.dotted * scaling])
}
ctx.beginPath()
switch (strokePos) {
case 'top':
ctx.moveTo(xStart - lineByTwo, yStart)
ctx.lineTo(xEnd + lineByTwo, yStart)
break
case 'bottom':
ctx.moveTo(xStart - lineByTwo, yEnd)
ctx.lineTo(xEnd + lineByTwo, yEnd)
break
case 'left':
ctx.moveTo(xStart, yStart - lineByTwo)
ctx.lineTo(xStart, yEnd + lineByTwo)
break
case 'right':
ctx.moveTo(xEnd, yStart - lineByTwo)
ctx.lineTo(xEnd, yEnd + lineByTwo)
break
}
ctx.stroke()
ctx.setLineDash([])
})
} }
if (this.stroke) {
if (this.debug) {
if (typeof this.stroke === 'string') { if (typeof this.stroke === 'string') {
ctx.strokeStyle = this.stroke ctx.strokeStyle = this.stroke
} else { } else {
ctx.strokeStyle = this.stroke.color ctx.strokeStyle = 'red'
ctx.lineWidth = this.stroke.width ctx.lineWidth = 1 * scaling
} }
ctx.strokeRect(...item) this.roundRect(ctx, ...item, false, true)
// ctx.strokeRect(...item)
}
this.postRender(ctx, ge)
}
public onUpdate(): void {
const ge = GameEngine.getGameEngine()
const scaling = ge.currentScene?.scale ?? 1
const min = Math.min(this.component.scale.x / 2, this.component.scale.y / 2)
this.borderRadius = {
topLeft: Math.min(typeof this.props.borderRadius === 'number' ? this.props.borderRadius : this.props.borderRadius?.topLeft ?? 0, min),
topRight: Math.min(typeof this.props.borderRadius === 'number' ? this.props.borderRadius : this.props.borderRadius?.topRight ?? 0, min),
bottomLeft: Math.min(typeof this.props.borderRadius === 'number' ? this.props.borderRadius : this.props.borderRadius?.bottomLeft ?? 0, min),
bottomRight:Math.min(typeof this.props.borderRadius === 'number' ? this.props.borderRadius : this.props.borderRadius?.bottomRight ?? 0, min)
}
if (this.props.stroke) {
if (typeof this.props.stroke === 'string') {
const stroke = { color: this.props.stroke, width: 1 }
this.stroke = {
top: stroke,
left: stroke,
right: stroke,
bottom: stroke
}
} else if ('color' in this.props.stroke) {
this.stroke = {
top: this.props.stroke,
left: this.props.stroke,
right: this.props.stroke,
bottom: this.props.stroke
}
} else {
this.stroke = this.props.stroke
}
} else {
this.stroke = undefined
} }
} }
/**
* Draws a rounded rectangle using the current state of the canvas.
* If you omit the last three params, it will draw a rectangle
* outline with a 5 pixel border radius
* @param {CanvasRenderingContext2D} ctx
* @param {Number} x The top left x coordinate
* @param {Number} y The top left y coordinate
* @param {Number} width The width of the rectangle
* @param {Number} height The height of the rectangle
* @param {Number} [radius.tl = 0] Top left
* @param {Number} [radius.tr = 0] Top right
* @param {Number} [radius.br = 0] Bottom right
* @param {Number} [radius.bl = 0] Bottom left
* @param {Boolean} [fill = false] Whether to fill the rectangle.
* @param {Boolean} [stroke = true] Whether to stroke the rectangle.
*/
// eslint-disable-next-line complexity
private roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
fill = false,
stroke = true
) {
ctx.beginPath()
// topLeft point
ctx.moveTo(x + this.borderRadius.topLeft, y)
// line to top right
ctx.lineTo(x + width - this.borderRadius.topRight, y)
// curve for top right
ctx.quadraticCurveTo(x + width, y, x + width, y + this.borderRadius.topRight)
// line to bottom right
ctx.lineTo(x + width, y + height - this.borderRadius.bottomRight)
// curve for the bottom right
ctx.quadraticCurveTo(x + width, y + height, x + width - this.borderRadius.bottomRight, y + height)
// line to bottom left
ctx.lineTo(x + this.borderRadius.bottomLeft, y + height)
// curve for bottom left
ctx.quadraticCurveTo(x, y + height, x, y + height - this.borderRadius.bottomLeft)
// line to top left
ctx.lineTo(x, y + this.borderRadius.topLeft)
// curve for top left
ctx.quadraticCurveTo(x, y, x + this.borderRadius.topLeft, y)
// end path
ctx.closePath()
// fill rect
if (fill) {
ctx.fill()
}
// stroke rect
if (stroke) {
ctx.stroke()
}
}
// private drawStroke(
// ctx: CanvasRenderingContext2D,
// ge: GameEngine,
// width: number,
// height: number,
// stroke: Stroke,
// options: StrokeOptions
// ) {
// const borders = this.getBorders(stroke)
// const firstBorderRadius = this.getRadius(width, height, borders[0])
// const secondBorderRadius = this.getRadius(width, height, borders[1])
// ctx.lineWidth = ge.currentScene?.scale ?? 1
// ctx.stroke()
// }
// private getStrokeSize(
// width: number,
// height: number,
// stroke: Stroke
// ): [[number, number], [number, number]] {
// const borders = this.getBorders(stroke)
// const firstBorderRadius = this.getRadius(width, height, borders[0])
// const secondBorderRadius = this.getRadius(width, height, borders[1])
// }
// /**
// * get the borders for a specific stroke
// *
// * @param stroke the stroke to get border
// * @returns the name of the borders of the stroke
// */
// private getBorders(stroke: Stroke): [Border, Border] {
// switch (stroke) {
// case 'top': return ['topLeft', 'topRight']
// case 'bottom': return ['bottomLeft', 'bottomRight']
// case 'left': return ['topLeft', 'bottomLeft']
// case 'right': return ['topRight', 'bottomRight']
// }
// }
// /**
// * get the border radius for a specific border
// *
// * @param width the width of the rectangle
// * @param height the height of the rectangle
// * @param border the border to find he radius
// * @returns the radius of the specified border
// */
// private getRadius(width: number, height: number, border: Border): number {
// if (!this.borderRadius) {
// return 0
// }
// const min = Math.min(width / 2, height / 2)
// if (typeof this.borderRadius === 'number') {
// return Math.min(this.borderRadius, min)
// }
// return Math.min(this.borderRadius[border] ?? 0)
// }
} }

View File

@ -1,44 +1,64 @@
import { objectLoop } from '@dzeio/object-util'
import GameEngine from 'GameEngine'
import Component2D from 'GameEngine/Component2D'
import Renderer from '.' import Renderer from '.'
import type GameEngine from '..'
interface Params { interface Params {
text?: string text?: string
size?: number
weight?: 'bold'
stroke?: string | {color: string, width: number}
color?: string
align?: 'left' | 'center' | 'right'
overrideSizeLimit?: boolean
} }
export default class TextRenderer extends Renderer { export default class TextRenderer extends Renderer<Params> {
public text?: string private width?: number
public size?: number
public weight?: 'bold'
public color?: string
public constructor(component: Component2D, params?: Params) { public async getWidth() {
super(component) return this.width ?? -1
objectLoop(params ?? {}, (v, k) => {this[k as 'text'] = v})
} }
// eslint-disable-next-line complexity
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
const position = this.getPosition() await super.render(ge, ctx)
const item: [number, number] = [ const globalScale = ge.currentScene?.scale ?? 1
// source x const item = this.preRender(ctx, ge)
// 0 - 1.5 - -1.5
position.x * (ge.caseSize.x),
// source y
position.y * (ge.caseSize.y)
]
const size = this.component.scale.y * ge.caseSize.y const size = this.component.scale.y * ge.caseSize.y
// console.log if (typeof this.props.text !== 'string') {
if (this.text) { if (this.debug) {
ctx.fillStyle = this.color ?? 'black' console.warn('no text, no display')
ctx.textBaseline = 'middle' }
ctx.textAlign = 'center' return
ctx.font = `${this.weight ? `${this.weight} ` : ''}${size + (this.size ?? 0)}px sans-serif`
ctx.fillText(this.text, ...item)
} }
ctx.textBaseline = 'top'
ctx.textAlign = this.props.align ?? 'left'
let posX = item[0]
if (this.props.align === 'center') {
posX += item[2] / 2
} else if (this.props.align === 'right') {
posX += item[2]
}
ctx.font = `${this.props.weight ? `${this.props.weight} ` : ''}${(this.props.size ?? size) / 16 * ge.caseSize.x * globalScale * 3}px sans-serif`
if (this.props.color) {
ctx.fillStyle = this.props.color ?? 'black'
ctx.fillText(this.props.text, posX, item[1], this.props.overrideSizeLimit ? undefined : item[2])
}
if (this.props.stroke) {
if (typeof this.props.stroke === 'string') {
ctx.strokeStyle = this.props.stroke
ctx.lineWidth = ge.currentScene?.scale ?? 1
} else {
ctx.strokeStyle = this.props.stroke.color
ctx.lineWidth = this.props.stroke.width * (ge.currentScene?.scale ?? 1)
}
ctx.strokeText(this.props.text, item[0], item[1], this.props.overrideSizeLimit ? undefined : item[2])
}
this.width = ctx.measureText(this.props.text).width / ge.caseSize.x / globalScale
this.postRender(ctx, ge)
} }
} }

View File

@ -1,9 +1,6 @@
import { objectLoop } from '@dzeio/object-util'
import GameEngine from 'GameEngine'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D from 'GameEngine/Component2D'
import Tileset from 'GameEngine/Tileset'
import Renderer from '.' import Renderer from '.'
import type GameEngine from '..'
import type Tileset from '../Tileset'
interface Params { interface Params {
tileset?: Tileset tileset?: Tileset
@ -13,30 +10,25 @@ interface Params {
/** /**
* TODO: Add origin support * TODO: Add origin support
*/ */
export default class TileRenderer extends Renderer implements Params { export default class TileRenderer extends Renderer<Params> {
public tileset?: Tileset
public id?: number
public constructor(component: Component2D, params?: Params) { public override async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
super(component) await super.render(ge, ctx)
objectLoop(params ?? {}, (v, k) => {this[k as 'id'] = v}) if (!this.props.tileset || typeof this.props.id !== 'number') {
}
public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) {
if (!this.tileset || typeof this.id !== 'number') {
return return
} }
const {sx, sy} = this.tileset.getSourceData(this.id) const {sx, sy} = this.props.tileset.getSourceData(this.props.id)
const position = this.getPosition() const position = this.getPosition()
await this.props.tileset.asset.load()
ctx.drawImage( ctx.drawImage(
await this.tileset.asset.get(), this.props.tileset.asset.get(),
sx, sx,
sy, sy,
this.tileset.width(this.id), this.props.tileset.width(this.props.id),
this.tileset.height(this.id), this.props.tileset.height(this.props.id),
position.x * (ge.caseSize.x), position.x * ge.caseSize.x,
position.y * (ge.caseSize.y), position.y * ge.caseSize.y,
(this.component.scale.x ?? ge.caseSize.x) * ge.caseSize.x, (this.component.scale.x ?? ge.caseSize.x) * ge.caseSize.x,
(this.component.scale.y ?? ge.caseSize.y) * ge.caseSize.y (this.component.scale.y ?? ge.caseSize.y) * ge.caseSize.y
) )

View File

@ -1,25 +1,157 @@
import GameEngine from 'GameEngine' import { objectLoop } from '@dzeio/object-util'
import Vector2D from 'GameEngine/2D/Vector2D' import GameEngine from '..'
import Component2D from 'GameEngine/Component2D' import Vector2D from '../2D/Vector2D'
import Component2D from '../Component2D'
import MathUtils from '../libs/MathUtils'
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
export default abstract class Renderer { export default abstract class Renderer<Props extends object = {}> {
public constructor(
protected component: Component2D
) {}
protected getPosition(): Vector2D { /**
const ge = GameEngine.getGameEngine() * set the renderer in debug mode
const realPosition = ge.currentScene?.camera.topLeft.sum(this.component.position) */
if (!realPosition) { public debug = false
console.error('no camera?!?')
return this.component.position public readonly props: Props
}
return new Vector2D( protected readonly defaultProps: Partial<Props> = {}
realPosition.x - this.component.scale.x / 2 - this.component.origin.x,
realPosition.y - this.component.scale.y / 2 - this.component.origin.y private oldProps?: Props
) private needUpdate = true
public constructor(
public readonly component: Component2D,
props: Props = {} as Props
) {
this.props = {...this.defaultProps, ...props}
} }
public abstract render(ge: GameEngine, ctx: CanvasRenderingContext2D): Promise<void> public setProps(newProps: Partial<Props>) {
objectLoop(newProps as any, (value, obj) => {
if (this.props[obj as keyof Props] === value) {
return
}
this.props[obj as keyof Props] = value
this.needUpdate = true
})
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async render(_ge: GameEngine, _ctx: CanvasRenderingContext2D): Promise<void> {
if (this.needUpdate) {
this.onUpdate?.(this.oldProps ?? this.props)
this.needUpdate = false
this.oldProps = this.props
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
public onUpdate?(oldProps: Props): void
/**
* return the position of the component with the camera offset applied
* @param component the component to get the real position
* @returns the position with the camera offset applied
*/
protected getPosition(component: Component2D = this.component): Vector2D {
const ge = GameEngine.getGameEngine()
let originComponent = component
while (originComponent.parent) {
originComponent = originComponent.parent
}
const realPosition = component.getAbsolutePosition()
.rotate(
originComponent.getAbsolutePosition(),
this.component.getAbsoluteRotation()
)
.sum(
-(ge.currentScene?.position?.x ?? 0),
-(ge.currentScene?.position?.y ?? 0)
)
return realPosition
}
/**
* transform the position of the object to the real position on the canvas
* @returns the position of the element and scale translated to the canvas positioning
*/
protected realPosition(): [number, number, number, number] {
const position = this.getPosition()
return [
this.translateToCanvas('x', position.x),
this.translateToCanvas('y', position.y),
this.translateToCanvas('x', this.component.scale.x),
this.translateToCanvas('y', this.component.scale.y)
]
}
/**
* Rotate the component
*
* It needs to be closed by rotateFinish
* @param ctx the context
* @param rotation rotation in degrees
*/
protected rotateStart(ctx: CanvasRenderingContext2D, sizes: [number, number, number, number], rotation: number) {
const radians = MathUtils.toRadians(rotation)
ctx.setTransform(
1, // Horizontal Scaling
0, // Horizontal Skewing
0, // Vertical Skewing
1, // Vertical Scaling
sizes[0], // Horizontal Moving
sizes[1] // Vertical Moving
)
ctx.rotate(radians)
}
/**
*
* @param ctx the context
* @param ge the gmeEngine
* @param additionnalRotation additionnal rotation
* @returns x, y, width, height
*/
protected preRender(
ctx: CanvasRenderingContext2D,
ge: GameEngine,
additionnalRotation = 0
): [number, number, number, number] {
let position = this.realPosition()
this.rotateStart(
ctx,
position,
this.component.getAbsoluteRotation() + additionnalRotation
)
position = [
0,
0,
position[2],
position[3]
]
return position
}
protected postRender(ctx: CanvasRenderingContext2D, ge: GameEngine) {
// handle rotation reset
ctx.resetTransform()
}
/**
* @param ctx the context
*/
protected rotateFinish(ctx: CanvasRenderingContext2D) {
ctx.resetTransform()
}
protected translateToCanvas(axis: 'x' | 'y', point: number): number {
const ge = GameEngine.getGameEngine()
const globalScale = ge.currentScene?.scale ?? 1
return point * ge.caseSize[axis] * globalScale
}
} }

View File

@ -1,27 +1,97 @@
import GameEngine from 'GameEngine' /* eslint-disable max-len */
import Camera from './Components/Camera' /* eslint-disable max-depth */
import Listener from '@dzeio/listener'
import GameEngine from '.'
import Checker from './2D/Collider/Checker'
import Vector2D from './2D/Vector2D'
import Component2D, { ComponentState } from './Component2D' import Component2D, { ComponentState } from './Component2D'
import Camera from './Components/Camera'
export default class Scene { export default class Scene extends Listener<{
componentAdded: (component: Component2D) => void
componentRemoved: (component: Component2D) => void
}> {
public static scenes: Record<string, Scene> = {} public static scenes: Record<string, Scene> = {}
public background?: string public background?: string
public id: string public id: string
public camera: Camera = new Camera() public position: Vector2D = new Vector2D(0)
public scale = 1
public globalRotation = 0
public components: Array<Component2D> = []
public camera?: Camera
private components: Array<Component2D> = [] public readonly updatePerformances: Record<string, number> = {
preparation: -1,
init: -1,
collision: -1,
update: -1,
render: -1,
total: -1
}
private componentsInitialized: Array<boolean> = []
private ge!: GameEngine private ge!: GameEngine
private hasClickedComponent: number | undefined
public constructor(sceneId: string) { public constructor(sceneId: string) {
super()
Scene.scenes[sceneId] = this Scene.scenes[sceneId] = this
this.id = sceneId this.id = sceneId
} }
public requireCamera() {
if (!this.camera) {
throw new Error('Camera not initialized')
}
return this.camera
}
public addComponent(...cp: Array<Component2D>) { public addComponent(...cp: Array<Component2D>) {
return this.components.push(...cp) if (!this.camera) {
const cam = cp.find((it) => it instanceof Camera) as Camera | undefined
this.camera = cam
}
this.componentsInitialized.push(...cp.map(() => false))
const size = this.components.push(...cp)
cp.forEach((it) => this.emit('componentAdded', it))
return size
}
public addComponentAt(index: number, ...cp: Array<Component2D>) {
if (!this.camera) {
const cam = cp.find((it) => it instanceof Camera) as Camera | undefined
this.camera = cam
}
const initStart = this.componentsInitialized.slice(0, index)
const initEnd = this.componentsInitialized.slice(index)
this.componentsInitialized = [...initStart, ...cp.map(() => false), ...initEnd]
const start = this.components.slice(0, index)
const end = this.components.slice(index)
this.components = [...start, ...cp, ...end]
cp.forEach((it) => this.emit('componentAdded', it))
}
public getComponents(): Array<Component2D> {
return this.components
}
/**
* delete the component
* @param component the component or component's index
*/
public removeComponent(component: number | Component2D): Scene {
component = typeof component !== 'number' ? this.components.findIndex((it) => it.id === (component as Component2D).id) : component
if (component !== -1) {
const cp = this.components.splice(component, 1)
this.emit('componentRemoved', cp[0])
this.componentsInitialized.splice(component, 1)
cp[0].destroy?.()
} else {
console.warn('component not found')
}
return this
} }
public setGameEngine(ge: GameEngine) { public setGameEngine(ge: GameEngine) {
@ -30,75 +100,294 @@ export default class Scene {
public async init() { public async init() {
for await (const component of this.components) { for await (const component of this.components) {
await component.init?.() await this.initComponent(component)
} }
} }
// private frameNumber = 0
// eslint-disable-next-line complexity
public async update() { public async update() {
for (let index = 0; index < this.components.length; index++) { // console.log('new scene frame', this.count++)
await this.updateComponent(this.components[index], index)
let now = window.performance.now()
const componentList = this.flattenComponents()
this.updatePerformances.preparation = window.performance.now() - now
now = window.performance.now()
await Promise.all(this.components.map((it) => this.initComponent(it)))
// for await (const component of this.components) {
// await this.initComponent(component)
// }
this.updatePerformances.init = window.performance.now() - now
// enabled && !checked && (!pPosition || diffPosition || checkNeeded)
now = window.performance.now()
const filterFn = (it: Component2D) =>
it.enabled &&
!it.state.collisionChecked &&
(!it.state.previousPosition || !it.getAbsolutePosition().equal(it.state.previousPosition) || it.state.collisionCheckNeeded)
let componentsToCheck: Array<Component2D> = componentList.filter(filterFn)
let doWhileLimit = 0
do {
// console.log(componentsToCheck)
const futureComponents: Array<Component2D> = []
componentsToCheck.forEach((it) => {
const collisions = this.checkColisions(it)
const oldCollisions = it.state.collisions ?? []
it.setState('previousPosition', it.getAbsolutePosition().clone())
it.setState('collisionChecked', true)
it.setState('collisions', collisions)
if (oldCollisions.length > 0|| collisions.length > 0) {
futureComponents.push(...[...oldCollisions, ...collisions].filter((coll) => !futureComponents.includes(coll.component)).map((coll) => {
coll.component.setState('collisionCheckNeeded', true)
return coll.component
}))
}
})
componentsToCheck = futureComponents.filter(filterFn)
} while (componentsToCheck.length > 0 && doWhileLimit++ < componentList.length)
this.updatePerformances.collision = window.performance.now() - now
now = window.performance.now()
for await (const component of componentList) {
// const cpNow = window.performance.now()
await this.updateComponent(component)
// const time = window.performance.now() - cpNow
// if (time > 0.15) {
// this.updatePerformances[`cp-${component.id} ${component.constructor.name}`] = window.performance.now() - cpNow
// }
}
componentList.forEach((it) => {
it.setState('collisionCheckNeeded', false)
it.setState('collisionChecked', false)
})
this.updatePerformances.update = window.performance.now() - now
now = window.performance.now()
const camera = this.camera
if (!camera) {
return
}
const camXY = camera.getAbsolutePosition(false)
const camPos = [camXY.clone(), camera.getScale().sum(camXY)]
const componentsToRender = componentList.filter((component) => {
const selfXY = component.getAbsolutePosition(false)
const selfPos = [selfXY, selfXY.sum(component.scale)]
// basic check
return selfPos[1].x >= camPos[0].x && // self bottom higher than other top
selfPos[0].x <= camPos[1].x &&
selfPos[1].y >= camPos[0].y &&
selfPos[0].y <= camPos[1].y
}).sort((a, b) => a.getAbsoluteZIndex() - b.getAbsoluteZIndex())
for await (const component of componentsToRender) {
await this.renderComponent(component)
}
this.updatePerformances.render = window.performance.now() - now
this.updatePerformances.total =
this.updatePerformances.preparation +
this.updatePerformances.collision +
this.updatePerformances.update +
this.updatePerformances.render
}
public destroy() {
for (const component of this.components) {
this.destroyComponent(component)
} }
} }
public async destroy() { public flattenComponents(): Array<Component2D> {
for await (const component of this.components) { return this.components.map(this.flattenComponent).flat()
await component.destroy?.()
}
} }
private async updateComponent(v: Component2D, index: number) { public orderComponentsByZIndex(): Array<Component2D> {
const debug = v.debug return this.flattenComponents().sort((a, b) => a.getAbsoluteZIndex() - b.getAbsoluteZIndex())
if (debug) { }
console.log('Processing Component', v)
// eslint-disable-next-line complexity
public checkColisions(component: Component2D): NonNullable<Component2D['state']['collisions']> {
if (!component.collider || !component.enabled) {
return []
} }
const state: Partial<ComponentState> = {}
// const width = (v.width() ?? 1) * this.ge.caseSize[0] const list: Component2D['state']['collisions'] = []
// const height = (v.height() ?? 1) * this.ge.caseSize[1]
if (v.collider && v.collider.type === 'click' && (this.hasClickedComponent === index || !this.hasClickedComponent)) { for (const otherComponent of this.flattenComponents()) {
if (v.collider.pointColliding(this.ge.cursor.position, 'click')) { if (
if (this.ge.cursor.isDown && !this.ge.cursor.wasDown) { !otherComponent.enabled ||
state.isColliding = 'click' otherComponent.id === component.id ||
this.hasClickedComponent = index !otherComponent.collider
} else if (this.ge.cursor.isDown) { ) {
state.isColliding = 'down' continue
this.hasClickedComponent = index }
const colliders = Array.isArray(component.collider) ? component.collider : [component.collider]
const otherColliders = Array.isArray(otherComponent.collider) ? otherComponent.collider : [otherComponent.collider]
for (const collider of colliders) {
for (const otherCollider of otherColliders) {
// Check for collision
if (collider.tags === null || otherCollider.tags === null) {
if (Checker.detectCollision(collider, otherCollider)) {
const tags = Array.isArray(collider.tags) ? collider.tags : [collider.tags]
for (const tag of tags) {
list.push({
collider: collider,
component: otherComponent,
tag: tag
})
}
}
continue
}
const colliderTypes: Array<string | undefined> = Array.isArray(collider.tags) ? collider.tags : [collider.tags]
const otherColliderTypes: Array<string | undefined> = Array.isArray(otherCollider.tags) ? otherCollider.tags : [otherCollider.tags]
const tagIdx = colliderTypes.filter((it) => otherColliderTypes.includes(it))
if (
tagIdx.length > 0 &&
Checker.detectCollision(collider, otherCollider)
) {
for (const tag of tagIdx) {
list.push({
collider: collider,
component: otherComponent,
tag: tag
})
}
continue
}
} }
} }
}
if (this.hasClickedComponent === index && !state.isColliding) {
this.hasClickedComponent = undefined
}
// if (v.pos) {
// const ax = v.pos.x * this.ge.caseSize[0]
// const ay = v.pos.y * this.ge.caseSize[1]
// state.mouseHovering =
// this.ge.cursor.x >= ax && this.ge.cursor.x < (ax + width) &&
// this.ge.cursor.y >= ay && this.ge.cursor.y < (ay + height)
// state.mouseClicking = state.mouseHovering && this.ge.cursor.isDown
// state.mouseClicked = state.mouseClicking && !this.ge.cursor.wasDown
// }
if (v.update) {
if (debug) {
console.log('Updating Component', v)
}
v.update(state as ComponentState)
}
if (v.renderer) {
if (debug) {
console.log('Rendering Component', v)
}
// console.log('is rendering new element')
await v.renderer.render(this.ge, this.ge.ctx)
} }
return list
}
if (v.childs) { /**
if (debug) { * check if an element collide with a specific position
console.log('Processing childs', v) * @param vector the position to check
*/
public at(pos: Vector2D, hasCollider = true) {
return this.components.filter((it) => {
if (hasCollider && !it.collider) {
return false
} }
for (let cIndex = 0; cIndex < v.childs.length; cIndex++) { return pos.isIn(it.position, it.position.sum(it.scale))
await this.updateComponent(v.childs[cIndex], cIndex) })
}
public in(pos: [Vector2D, Vector2D], hasCollider = true) {
return this.components.filter((it) => {
if (hasCollider && !it.collider) {
return false
}
return Checker.posBoxBoxCollision(
pos,
[it.position, it.position.sum(it.scale)]
)
})
}
private flattenComponent = (component: Component2D): Array<Component2D> => {
if (!component.enabled) {
return []
}
if (!component.childs) {
return [component]
}
return [component, ...component.childs.map(this.flattenComponent).flat()]
}
private async initComponent(component: Component2D) {
if (component.props.initialized) {
return
}
if (component.init) {
await component?.init()
}
component.setProps({initialized: true})
if (component.childs) {
for await (const child of component.childs) {
child.parent = component
await this.initComponent(child)
} }
} }
} }
/**
* Update a specific component
*
* note: It first update the childs THEN the component
*
* @param component the component to update
* @returns the list of components to exclude in collision check
*/
// eslint-disable-next-line complexity
private async updateComponent(component: Component2D): Promise<void> {
if (!component.enabled) {
return
}
// update childs first
// const toExclude: Array<Component2D> = []
// if (component.childs && component.childs.length > 0) {
// for await (const child of component.childs) {
// toExclude.push(...await this.updateComponent(child))
// }
// }
const state: Partial<ComponentState> = component.state
component.setProps({scene: this})
if (component.update) {
return component.update(state as ComponentState)
}
}
// eslint-disable-next-line complexity
private async renderComponent(component: Component2D) {
if (!component.enabled) {
return
}
const debug = component.debug
if (debug) {
console.group('rendering: ', component.id)
}
if (component.renderer) {
if (debug) {
console.log('rendering!')
}
// console.log('is rendering new element')
await component.renderer.render(this.ge, this.ge.ctx)
} else if (debug) {
console.log('component has no renderer')
}
if (component.debug) {
console.groupEnd()
}
}
private destroyComponent(component: Component2D) {
for (const child of component.childs) {
this.destroyComponent(child)
}
component.destroy?.()
component.setProps({initialized: false})
}
} }
/*
if (component.hasMoved) check collisions
update item
if (in camera) render it
*/

View File

@ -32,7 +32,9 @@ export default class Tileset {
} }
// const {x, y} = this.getPosFromId(id) // const {x, y} = this.getPosFromId(id)
const cols = Math.trunc(this.declaration.fileSize.width / this.width(id)) const cols = Math.trunc(this.declaration.fileSize.width / this.width(id))
// eslint-disable-next-line id-length
const x = id % cols const x = id % cols
// eslint-disable-next-line id-length
const y = Math.trunc(id / cols) const y = Math.trunc(id / cols)
const sx = x * this.width(id) + x * (this.declaration.spacing ?? 0) const sx = x * this.width(id) + x * (this.declaration.spacing ?? 0)
const sy = y * this.height(id) + y * (this.declaration.spacing ?? 0) const sy = y * this.height(id) + y * (this.declaration.spacing ?? 0)

View File

@ -1,10 +1,10 @@
import { objectMap, objectValues } from '@dzeio/object-util'
import Vector2D from './2D/Vector2D' import Vector2D from './2D/Vector2D'
import Scene from './Scene' import Scene from './Scene'
/** /**
* TODO: * TODO:
* Animation Engine * Animation Engine
* Camera fined control
* Collision * Collision
*/ */
export default class GameEngine { export default class GameEngine {
@ -12,16 +12,9 @@ export default class GameEngine {
public ctx: CanvasRenderingContext2D public ctx: CanvasRenderingContext2D
public canvas: HTMLCanvasElement public canvas: HTMLCanvasElement
public caseSize: Vector2D = new Vector2D(1, 1) public caseSize: Vector2D = new Vector2D(1, 1)
public cursor: { public componentId = 0
position: Vector2D
isDown: boolean public currentScene?: Scene | null
wasDown: boolean
} = {
position: new Vector2D(0, 0),
isDown: false,
wasDown: false
}
public currentScene?: Scene
// last frame timestamp // last frame timestamp
public lastFrame = 0 public lastFrame = 0
@ -33,13 +26,18 @@ export default class GameEngine {
*/ */
public frameTime = 0 public frameTime = 0
private isRunning = false /**
* indicate if the engine is running
*/
public isRunning = false
// timer between frames // timer between frames
private timer = 0 private timer = 0
private loopId?: number
// eslint-disable-next-line complexity
public constructor( public constructor(
id: string, id: string,
public options?: { public options?: {
@ -49,11 +47,15 @@ export default class GameEngine {
/** /**
* Maximum framerate you want to achieve * Maximum framerate you want to achieve
* *
* note: -1 mean infinite * note: -1/undefined mean infinite
*/ */
goalFramerate?: number goalFramerate?: number
} }
) { ) {
console.log('Setting up GameEngine')
if (GameEngine.ge) {
throw new Error('GameEngine already init')
}
GameEngine.ge = this GameEngine.ge = this
const canvas = document.querySelector<HTMLCanvasElement>(id) const canvas = document.querySelector<HTMLCanvasElement>(id)
if (!canvas) { if (!canvas) {
@ -62,10 +64,12 @@ export default class GameEngine {
this.canvas = canvas this.canvas = canvas
if (this.options?.caseCount) { if (this.options?.caseCount) {
this.caseSize = new Vector2D( this.caseSize = new Vector2D(
// @ts-expect-error idc this.canvas.width / (
this.canvas.width / ((typeof this.options.caseCount) !== 'number' ? this.options.caseCount[0] : this.options.caseCount ), typeof this.options.caseCount !== 'number' ? this.options.caseCount[0] : this.options.caseCount
// @ts-expect-error idc2 lol ),
this.canvas.height / ((typeof this.options.caseCount) !== 'number' ? this.options.caseCount[1] : this.options.caseCount) this.canvas.height / (
typeof this.options.caseCount !== 'number' ? this.options.caseCount[1] : this.options.caseCount
)
) )
} }
@ -73,7 +77,8 @@ export default class GameEngine {
if (!ctx) { if (!ctx) {
throw new Error('Error, Context could not get found!') throw new Error('Error, Context could not get found!')
} }
ctx.imageSmoothingEnabled = false ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
this.ctx = ctx this.ctx = ctx
if (options?.goalFramerate && options.goalFramerate >= 0) { if (options?.goalFramerate && options.goalFramerate >= 0) {
@ -82,7 +87,10 @@ export default class GameEngine {
} }
public static getGameEngine(): GameEngine { public static getGameEngine(): GameEngine {
return this.ge // if (!this.ge) {
// throw new Error('Game Engine not initialized!')
// }
return this.ge as GameEngine
} }
public start() { public start() {
@ -91,94 +99,139 @@ export default class GameEngine {
return return
} }
this.isRunning = true this.isRunning = true
this.currentScene?.init().then(() => this.update()) this.currentScene!.init().then(() => this.update())
document.addEventListener('mousemove', (ev) => {
this.cursor.position = new Vector2D(
(ev.clientX + window.scrollX) / this.caseSize.x - (this.currentScene?.camera?.topLeft?.x ?? 0),
(ev.clientY + window.scrollY) / this.caseSize.y - (this.currentScene?.camera?.topLeft?.y ?? 0)
)
if (this.cursor.isDown) {
this.cursor.wasDown = true
}
})
document.addEventListener('mousedown', () => {
console.log('cursor down')
this.cursor.isDown = true
})
document.addEventListener('mouseup', () => {
console.log('cursor up')
this.cursor.isDown = false
this.cursor.wasDown = false
})
} }
public pause() { public pause() {
this.isRunning = false this.isRunning = false
} }
public debugPerformances() {
setInterval(() => {
console.log(
'\n',
...objectMap(this.currentScene?.updatePerformances ?? {}, (v, k) => [k, v.toFixed(2) + 'ms\n']).flat()
)
}, 100)
}
public destroy() {
this.isRunning = false
for (const scene of objectValues(Scene.scenes)) {
scene.destroy()
}
if (GameEngine.ge) {
// @ts-expect-error normal behavior
delete GameEngine.ge as any
}
if (this.loopId) {
clearInterval(this.loopId)
}
}
public getXCaseCount(): number {
const caseCount = this.options?.caseCount
if (caseCount) {
if (typeof caseCount === 'number') {
return caseCount
} else {
return caseCount[0]
}
}
return this.canvas.offsetWidth
}
public getYCaseCount(): number {
const caseCount = this.options?.caseCount
if (caseCount) {
if (typeof caseCount === 'number') {
return caseCount
} else {
return caseCount[1]
}
}
return this.canvas.offsetWidth
}
public async setScene(scene: Scene | string) { public async setScene(scene: Scene | string) {
console.log('Setting scene', typeof scene === 'string' ? scene : scene.id) console.log('Setting scene', typeof scene === 'string' ? scene : scene.id)
const wasRunning = this.isRunning const wasRunning = this.isRunning
if (wasRunning) { if (wasRunning) {
this.isRunning = false this.isRunning = false
} }
await this.currentScene?.destroy() this.currentScene?.destroy()
await this.currentScene?.init()
if (wasRunning) { if (wasRunning) {
this.isRunning = true this.isRunning = true
} }
this.currentScene = typeof scene === 'string' ? Scene.scenes[scene] : scene const res = typeof scene === 'string' ? Scene.scenes[scene] : scene
if (!res) {
throw new Error('Scene not found!')
}
this.currentScene = res
await this.currentScene?.init()
this.currentScene.setGameEngine(this) this.currentScene.setGameEngine(this)
} }
private async update() { private update() {
// console.log('update') if (this.loopId) {
let frameFinished = true console.error('Already initialized')
setInterval((it) => { return
// get current time }
const now = window.performance.now()
// game is not runnig, wait a frame // indicate if the main loop has started
if (!this.isRunning || !frameFinished) { let run = false
// console.log('skip frame') this.loopId = requestAnimationFrame(() => {
// setTimeout(() => { run = true
// this.update() this.loop()
// }, this.timer)
return
}
// game is running too fast, wait until necessary
if (this.lastFrame + this.timer > now ) {
// console.log('skip frame')
// setTimeout(() => {
// this.update()
// }, (this.lastFrame + this.timer) - now)
return
}
// console.log('new frame')
frameFinished = false
// if a background need to be drawn
if (this.options?.background) {
this.ctx.fillStyle = this.options.background
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
} else {
// clear the previous frame
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
// update scene
this.currentScene?.update()
// calculate for next frame
this.lastFrame = window.performance.now()
this.frameTime = window.performance.now() - now
frameFinished = true
// this.update()
// requestAnimationFrame(() => {
// })
}) })
// sometime the main loop do not start so we need to try again
setTimeout(() => {
if (!run) {
clearInterval(this.loopId)
delete this.loopId
this.update()
}
}, 20)
} }
private async loop() {
// get current time
const now = window.performance.now()
// game is not runnig, wait a frame
if (!this.isRunning) {
// console.log('skip frame 1')
this.loopId = requestAnimationFrame(() => this.loop())
return
}
// game is running too fast, wait until necessary
if (this.lastFrame + this.timer > now) {
// console.log('skip frame 2')
this.loopId = requestAnimationFrame(() => this.loop())
return
}
// console.log('new frame')
// if a background need to be drawn
if (this.options?.background) {
this.ctx.fillStyle = this.options.background
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
} else {
// clear the previous frame
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
await this.currentScene?.update()
// calculate for next frame
this.lastFrame = window.performance.now()
this.frameTime = window.performance.now() - now
this.loopId = requestAnimationFrame(() => this.loop())
}
} }
export interface GameState<UserState = any> { export interface GameState<UserState = any> {

View File

@ -0,0 +1,11 @@
/**
* Allow to quickly apply elements to item through fn
*
* @param item the variable to apply childs
* @param fn the function to run
* @returns item with elements modified from fn
*/
export function apply<T>(item: T, fn: (this: T, item: T) => void): T {
fn.call(item, item)
return item
}

View File

@ -0,0 +1,48 @@
export default class MathUtils {
/**
* round the value to the nearest value
*
* ex: [88, 45] round to 90, while [50, 45] round to 45
* @param value the value to round
* @param near the multiplier to near to
* @returns the value closest to the nearest [near]
*/
public static roundToNearest(value: number, near: number): number {
// get the remainder of the division
const remainder = value % near
// if remainder is 0 then no need to round
if (remainder === 0 || near <= 0) {
return value
}
// round down if value is less than near / 2
if (remainder < near / 2) {
return value - remainder
}
// round up
return value - remainder + near
}
/**
* clamp the specified value between two other values
* @param value the value to clamp
* @param min the minimum value
* @param max the maxmimum
* @returns the value clamped between [min] and [max]
*/
public static clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
/**
* transform degrees to radians
* @param deg the value in degrees
* @returns the value in radians
*/
public static toRadians(deg: number): number {
deg = deg % 360
if (deg < 0) {
deg = 360 + deg
}
return deg * (Math.PI / 180)
}
}

7
src/assets/README.md Normal file
View File

@ -0,0 +1,7 @@
# Assets
Contains images that can be imported directly into the application
# Folder Architecture
- /assets/[path to element from src]/[folder named as the element]/[assets of the element].[ext]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -1,4 +0,0 @@
declare module '*.styl' {
const content: any
export = content
}

View File

@ -0,0 +1,24 @@
---
import { getImage } from 'astro:assets'
export interface Props {
svg: ImageMetadata
png: ImageMetadata
icoPath?: string
}
if (Astro.props.icoPath !== '/favicon.ico') {
console.warn('It is recommanded that the ICO file should be located at /favicon.ico')
}
const appleTouch = await getImage({src: Astro.props.png, width: 180, height: 180})
---
<>
<link rel="icon" href={Astro.props.icoPath ?? "/favicon.ico"} sizes="any">
<link rel="icon" href={Astro.props.svg.src} type="image/svg+xml">
<link rel="apple-touch-icon" href={appleTouch.src} />
<!-- Currently not integrated until I find a way to. -->
<!-- <link rel="manifest" href="/site.webmanifest" /> -->
</>

View File

@ -0,0 +1,36 @@
import { getImage } from 'astro:assets'
export default class Manifest {
static async create(baseImage: ImageMetadata, options: {
name: string
color?: string
images?: Array<number>
}) {
const [
i192,
i512
] = await Promise.all([
getImage({src: baseImage, format: 'png', width: 192, height: 192}),
getImage({src: baseImage, format: 'png', width: 512, height: 512})
])
return JSON.stringify({
name: options.name,
short_name: options.name,
icons: [
{
src: i192.src,
sizes: "192x192",
type: "image/png"
},
{
src: i512.src,
sizes: "512x512",
type: "image/png"
}
],
theme_color: options.color ?? "#fff",
background_color: options.color ?? "#fff",
display: "standalone"
})
}
}

View File

@ -0,0 +1,4 @@
---
const json = JSON.stringify(Astro.props)
---
<script id="ASTRO_DATA" is:inline type="application/json" set:html={json}></script>

View File

@ -0,0 +1,10 @@
/**
* note: you MUST only pass simple items that can go in JSON format natively
*/
export function load<T extends {} = {}>(): T {
const tag = document.querySelector<HTMLScriptElement>('#ASTRO_DATA')
if (!tag) {
throw new Error('could not load client variables, tag not found')
}
return JSON.parse(tag.innerText)
}

View File

@ -0,0 +1,42 @@
---
import { LocalImageProps, RemoteImageProps, getImage } from 'astro:assets'
import AstroUtils from '../libs/AstroUtils'
type ImageProps = LocalImageProps | RemoteImageProps
export type Props = ImageProps
const res = await AstroUtils.wrap(async () => {
const image = Astro.props.src
const ext = typeof image === 'string' ? image.substring(image.lastIndexOf('.')) : image.format
if (ext === 'svg') {
return {
format: 'raw',
props: {
...Astro.props,
src: typeof image === 'string' ? image : image.src
}
}
}
const avif = await getImage({src: Astro.props.src, format: 'avif'})
const webp = await getImage({src: Astro.props.src, format: 'webp'})
const orig = await getImage({src: Astro.props.src, format: ext})
return {
format: 'new',
avif,
webp,
orig
}
})
---
{res.format === 'new' && (
<picture class:list={[res.orig!.attributes.class, Astro.props.class]}>
<source srcset={res.avif!.src} type="image/avif" />
<source srcset={res.webp!.src} type="image/webp" />
<img src={res.orig!.src} class="" {...res.orig!.attributes} />
</picture>
) || (
<img {...res.props} />
)}

3
src/components/README.md Normal file
View File

@ -0,0 +1,3 @@
# Components
Contains big elements that can be reused by themselve

5
src/content/README.md Normal file
View File

@ -0,0 +1,5 @@
# Content
Contains raw content for pages.
Mostly some static pages or blog posts.

15
src/content/config.ts Normal file
View File

@ -0,0 +1,15 @@
// 1. Import utilities from `astro:content`
// import { defineCollection, z } from 'astro:content'
// 2. Define your collection(s)
// const docsCollection = defineCollection({
// type: 'content',
// schema: z.object({
// title: z.string()
// })
// })
// 3. Export a single `collections` object to register your collection(s)
// This key should match your collection directory name in "src/content"
// export const collections = {
// 'docs': docsCollection,
// };

23
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client-image" />
/// <reference path="./libs/ResponseBuilder" />
/**
* Environment variables declaration
*/
interface ImportMetaEnv {
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare namespace App {
/**
* Middlewares variables
*/
interface Locals {
responseBuilder: ResponseBuilder
}
}

View File

@ -0,0 +1,304 @@
import States from '../../libs/States'
export interface Cell {
// cell pokemon ID
id: number
// is cell part of an horizontal combo
horizontalCombo?: boolean
// is cell part of a vertical combo
verticalCombo?: boolean
// has cell just spawned?
justSpawned?: boolean
// is cell falling
isFalling?: boolean
}
export default class PokemonShuffle {
public gameState: States<{
cells: Array<Array<Cell | null>>
// boss damages
damage: number
}> = new States({
cells: [[]] as any,
damage: 0
})
private comboMax: number = 0
public constructor(
private boardSize: number = 6,
private pokemonCount: number = 5,
public readonly boss: {
health: number
id: number
}
) {
this.gameState.setProps({cells: this.generate()})
}
public async moveCell(from: [number, number], to: [number, number]) {
// move cells
const cellFrom = this.gameState.props.cells[from[0]]![from[1]] ?? null
const cellTo = this.gameState.props.cells[to[0]]![to[1]] ?? null
this.gameState.props.cells[from[0]]![from[1]] = cellTo
this.gameState.props.cells[to[0]]![to[1]] = cellFrom
// run the main loop
let newScore = 0
let runCheckLoop = true
while (runCheckLoop) {
const scoreMade = this.calculateGame()
runCheckLoop = scoreMade > 0
if (scoreMade > 0) {
await this.updateGameState()
}
newScore += scoreMade
}
// revert if no combo were made
if (newScore === 0) {
this.gameState.props.cells[from[0]]![from[1]] = cellFrom
this.gameState.props.cells[to[0]]![to[1]] = cellTo
}
}
/**
* generate a new game
*/
private generate(): Array<Array<Cell | null>> {
return Array
.from(Array(this.boardSize))
.map(
() => Array.from(Array(this.boardSize))
.map(() => ({ id: this.randomPokemon(0, this.pokemonCount) }))
)
}
private randomPokemon(min = 0, max = 100): number {
let id = -1
do {
id = Math.floor(Math.random() * (max - min) + min)
// dont return 1 as it is the `?`
} while (id < 0 || id === 1)
return id
}
private async updateGameState() {
// remove combos
const cells = this.gameState.props.cells.map((row) => row.map((cell) => {
if (!cell) {
return null
}
return cell
}))
let needContinue = true
let hadTurn = false
while (needContinue) {
console.log('running calculate')
// Make items fall
needContinue = false
for (let rowIdx = (cells.length - 1); rowIdx >= 0; rowIdx--) {
const row = cells[rowIdx] ?? []
for (let colIdx = 0; colIdx < row.length; colIdx++) {
const cell = row[colIdx]
if (cell) {
cell.justSpawned = false
cell.isFalling = false
}
if (cell && rowIdx+1 < row.length && !cells[rowIdx+1]?.[colIdx]) {
cell.isFalling = true
needContinue = true
console.log('making', rowIdx, colIdx, 'fall')
// Move cell down
cells![rowIdx+1]![colIdx] = cell
cells![rowIdx]![colIdx] = null
}
}
}
// Fill the top lane
for (let col = 0; col < cells[0]!.length; col++) {
const cell = cells![0]![col]
if (!cell) {
console.log('filling to lane at', col)
needContinue = true
cells[0]![col] = {id: this.randomPokemon(0, this.pokemonCount), justSpawned: true}
}
}
// Need to wait for the falling animation
if (needContinue) {
this.gameState.setProps({
cells: cells,
// hitBoss: false
}, {force: true})
await this.wait(300)
}
// Clear items
let hasCleared = false
for (const row of cells) {
for (let colIdx = 0; colIdx < row.length; colIdx++) {
const cell = row[colIdx]
if (!cell || (!cell.horizontalCombo && !cell.verticalCombo)) {continue}
row[colIdx] = null
hasCleared = true
needContinue = true
console.log('clearing', colIdx)
}
}
if (hasCleared/* && !initial*/) {
await this.wait(500)
}
hadTurn = true
}
return hadTurn
}
private wait(time: number): Promise<void> {
return new Promise((res) => setTimeout(() => res(), time))
}
private calculateGame() {
const items = this.gameState.props.cells
// the number of matches found
let matches = 0
// the score the calculation made
let newPoints = 0
// the current combo
let combo = 0
// loop through each rows
for (let rowIdx = 0; rowIdx < items.length; rowIdx++) {
const row = items[rowIdx] ?? []
// loop through each cols
for (let colIdx = 0; colIdx < row.length; colIdx++) {
const cell = row[colIdx]
// skip if there is no cell
if (!cell) {
continue
}
// get the pokemon ID
const id = cell.id
// Checkup combos horizontally
if (!cell.horizontalCombo && !(cell.isFalling || cell.justSpawned)) {
// the number of time it is seen
let count = 0
while((colIdx + ++count) < items.length) {
// check the card below
const tmp = row[colIdx + count]
if (!tmp || tmp.id !== id || tmp.isFalling || tmp.justSpawned) {break}
}
if (count >= 3) {
matches += 1
for (let i = colIdx; i < (colIdx + count); i++) {
const tmp = items[rowIdx]![i]
if (!tmp) {continue}
tmp.horizontalCombo = true
}
newPoints += this.calculateMatch(count, combo)
}
}
// Checkup combos Vertical
if (!cell.verticalCombo && !(cell.isFalling || cell.justSpawned)) {
let count = 0
while((rowIdx + ++count) < items.length) {
const tmp = items[rowIdx + count]![colIdx]
if (!tmp || tmp.id !== id || tmp.isFalling || tmp.justSpawned) {break}
}
if (count >= 3) {
matches += 1
for (let i = rowIdx; i < (rowIdx + count); i++) {
const tmp = items[i]![colIdx]
if (!tmp) {continue}
tmp.verticalCombo = true
}
newPoints += this.calculateMatch(count, combo)
// console.log(colIdx, rowIdx)
}
}
}
}
// If combos were found
if (matches) {
combo += matches
const comboMax = Math.max(this.comboMax, combo)
// if (comboMax === combo && !initial) {
// window.localStorage.setItem('pokemon-shuffle/comboMax', comboMax.toString())
// }
this.gameState.setProps({
cells: items
// damage: state.props.damage ?? 0 + newPoints,
// combo,
// comboMax,
// hitBoss: true
}, { force: true })
}
return newPoints
}
/**
* Calculate a score for a specific match
*
* @param len the length of the match
* @param combo the current combo count
* @returns the score for the specific match
*/
private calculateMatch(len: number, combo: number) {
let score = (len - 2) * 40 // currently the damage
if (len > 3) {
switch (len) {
case 4:
score *= 1.5
break
case 5:
score *= 2
break
case 6:
score *= 3
break
default:
break
}
}
if (combo > 1) {
if (combo >= 2 && combo <= 4) {
score *= 1.1
}
if (combo >= 5 && combo <= 9) {
score *= 1.15
}
if (combo >= 10 && combo <= 24) {
score *= 1.2
}
if (combo >= 25 && combo <= 49) {
score *= 1.3
}
if (combo >= 50 && combo <= 74) {
score *= 1.4
}
if (combo >= 75 && combo <= 99) {
score *= 1.5
}
if (combo >= 100 && combo <= 199) {
score *= 2
}
if (combo >= 200) {
score *= 2.5
}
}
return score
}
}

View File

@ -1,48 +0,0 @@
import { stat } from 'fs'
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import ColliderDebugger from 'GameEngine/2D/Debug/ColliderDebugger'
import PointDebugger from 'GameEngine/2D/Debug/PointDebugger'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import Renderer from 'GameEngine/Renderer'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import { globalState } from '.'
import Cursor from './Cursor'
import Space from './Space'
import TextComponent from './TextComponent'
export default class Button extends Component2D {
private static isOnCursor = false
public size = {
width: 3, height: 1
}
public renderer: RectRenderer = new RectRenderer(this)
public collider: BoxCollider2D = new BoxCollider2D(this, 'click')
public position: Vector2D = new Vector2D(10, 5)
public scale: Vector2D = new Vector2D(20, 10)
public constructor() {
super()
this.childs = [
new TextComponent(this, 'remèttre à zéro', 'bold', 10, 'white')
]
this.renderer.material = 'black'
}
public update(state: ComponentState): void | Promise<void> {
if (state.isColliding === 'click' || state.isColliding === 'down') {
Space.shouldReset = true
globalState.x10Moved = 0
globalState.x20Moved = 0
} else {
Space.shouldReset = false
}
}
}

View File

@ -1,27 +0,0 @@
import { stat } from 'fs'
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import Renderer from 'GameEngine/Renderer'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
export default class Cursor extends Component2D {
public renderer: RectRenderer = new RectRenderer(this)
public collider: BoxCollider2D = new BoxCollider2D(this, 'click')
public position: Vector2D = new Vector2D(0, 0)
public scale: Vector2D = new Vector2D(1, 1)
// public origin: Vector2D = new Vector2D(0.5, 0.5)
public update(state: ComponentState): void | Promise<void> {
// state.mouseHovering
this.renderer.material = 'blue'
const cursor = GameEngine.getGameEngine().cursor
this.position = cursor.position
}
}

View File

@ -1,106 +0,0 @@
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import { globalState } from '.'
import Cursor from './Cursor'
import TextComponent from './TextComponent'
export default class Space extends Component2D {
public static shouldReset = false
public size = {
width: 3, height: 1
}
public renderer: RectRenderer = new RectRenderer(this)
public collider: BoxCollider2D = new BoxCollider2D(this, 'click')
public position: Vector2D = new Vector2D(0, 0)
public scale: Vector2D = new Vector2D(30, 10)
private posBeforeCursor: Vector2D | null = null
private basePosition: Vector2D
private hasMoved = false
public constructor(
position: Vector2D,
size: Vector2D,
private cursor: Cursor,
private placeableRects: Array<[Vector2D, Vector2D]>,
private log: boolean = false
) {
super()
this.position = position
this.basePosition = position
// this.debug = true
this.scale = size
const text = `${size.x}x${size.y}`
this.childs = [
new TextComponent(this, text, 'bold', 8, 'blue')
]
}
public update(state: ComponentState): void | Promise<void> {
// state.mouseHovering
const point = this.scale.div(2).sub(this.position)
this.renderer.material = 'white'
this.renderer.stroke = {color: 'black', width: 3}
const cursor = GameEngine.getGameEngine().cursor
if (this.log) console.log(Space.shouldReset)
if (state.isColliding === 'click' || state.isColliding === 'down') {
if (!this.hasMoved) {
this.hasMoved = true
if (this.scale.x === 10) {
globalState.x10Moved ++
} else {
globalState.x20Moved ++
}
}
this.renderer.stroke = {color: 'green', width: 3}
if (!this.posBeforeCursor) {
this.posBeforeCursor = this.position
}
// console.log('follow cursor', cursor.position, this.position)
this.position = cursor.position
let canPlace = false
for (const placeableRect of this.placeableRects) {
if (point.isIn(placeableRect[0], this.scale.sub(placeableRect[1]))) {
canPlace = true
break
}
}
if (!canPlace) {
this.renderer.stroke = {color: 'red', width: 3}
}
else {
this.posBeforeCursor = this.position
}
} else if (this.posBeforeCursor) {
const futurePosition = this.position.decimalCount(0)
let canPlace = false
for (const placeableRect of this.placeableRects) {
if (point.isIn(placeableRect[0], this.scale.sub(placeableRect[1]))) {
canPlace = true
break
}
}
if (!canPlace) {
this.position = this.posBeforeCursor.decimalCount(0)
} else {
this.position = futurePosition
}
this.posBeforeCursor = null
}
if (Space.shouldReset) {
this.position = this.basePosition
this.hasMoved = false
}
}
}

View File

@ -1,51 +0,0 @@
import { stat } from 'fs'
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import ColliderDebugger from 'GameEngine/2D/Debug/ColliderDebugger'
import PointDebugger from 'GameEngine/2D/Debug/PointDebugger'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import Renderer from 'GameEngine/Renderer'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import TextRenderer from 'GameEngine/Renderer/TextRenderer'
import { globalState } from '.'
import Cursor from './Cursor'
export default class Text extends Component2D {
public renderer: TextRenderer = new TextRenderer(this)
// public position: Vector2D = new Vector2D(0, 0)
public constructor(private type: keyof typeof globalState, weight?: 'bold', size?: number, color?: string) {
super()
switch (type) {
case 'x10Moved':
this.position = new Vector2D(10, 40)
break
case 'x20Moved':
this.position = new Vector2D(10, 43)
break
default:
break
}
this.renderer.text = 'nique'
this.renderer.weight = weight
this.renderer.size = size
this.renderer.color = color
}
public update(state: ComponentState): void | Promise<void> {
switch (this.type) {
case 'x10Moved':
this.renderer.text = '10x20: ' + (5 - globalState.x10Moved)
break
case 'x20Moved':
this.renderer.text = '10x20: ' + (5 - globalState.x20Moved)
break
default:
break
}
}
}

View File

@ -1,31 +0,0 @@
import { stat } from 'fs'
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import ColliderDebugger from 'GameEngine/2D/Debug/ColliderDebugger'
import PointDebugger from 'GameEngine/2D/Debug/PointDebugger'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import Renderer from 'GameEngine/Renderer'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import TextRenderer from 'GameEngine/Renderer/TextRenderer'
import Cursor from './Cursor'
export default class TextComponent extends Component2D {
public renderer: TextRenderer = new TextRenderer(this)
// public position: Vector2D = new Vector2D(0, 0)
public constructor(private parent: Component2D, text: string, weight?: 'bold', size?: number, color?: string) {
super()
this.renderer.text = text
this.renderer.weight = weight
this.renderer.size = size
this.renderer.color = color
}
public update(state: ComponentState): void | Promise<void> {
this.position = this.parent.position
}
}

View File

@ -1,7 +0,0 @@
export const globalState: {
x20Moved: number
x10Moved: number
} = {
x10Moved: 0,
x20Moved: 0
}

View File

@ -1,9 +1,9 @@
import Vector2D from 'GameEngine/2D/Vector2D' import type Vector2D from '../../GameEngine/2D/Vector2D'
import Asset from 'GameEngine/Asset' import Asset from '../../GameEngine/Asset'
import Component2D from 'GameEngine/Component2D' import Component2D from '../../GameEngine/Component2D'
import TileRenderer from 'GameEngine/Renderer/TileRenderer' import TileRenderer from '../../GameEngine/Renderer/TileRenderer'
import SoundManager from 'GameEngine/SoundManager' import SoundManager from '../../GameEngine/SoundManager'
import Tileset from 'GameEngine/Tileset' import Tileset from '../../GameEngine/Tileset'
export class Explosion extends Component2D { export class Explosion extends Component2D {
@ -12,6 +12,8 @@ export class Explosion extends Component2D {
height: .9 height: .9
} }
public ended = true
// public origin = new Vector2D(-.5, -.5) // public origin = new Vector2D(-.5, -.5)
private explosionTileset = new Tileset(Asset.init('/assets/tictactoe/explosion.png'), { private explosionTileset = new Tileset(Asset.init('/assets/tictactoe/explosion.png'), {
@ -22,7 +24,7 @@ export class Explosion extends Component2D {
private n = 0 private n = 0
public update() { public override update() {
if (this.animationNumber !== -1 && this.n++ >= 12) { if (this.animationNumber !== -1 && this.n++ >= 12) {
this.renderer = new TileRenderer(this, { this.renderer = new TileRenderer(this, {
id: this.animationNumber++, id: this.animationNumber++,
@ -31,7 +33,8 @@ export class Explosion extends Component2D {
this.n = 0 this.n = 0
if (this.animationNumber > 5) { if (this.animationNumber > 5) {
this.animationNumber = -1 this.animationNumber = -1
this.renderer = undefined this.renderer = null
this.ended = true
} }
} }
} }
@ -40,5 +43,6 @@ export class Explosion extends Component2D {
new SoundManager('/assets/tictactoe/explosion.wav').play() new SoundManager('/assets/tictactoe/explosion.wav').play()
this.position = pos this.position = pos
this.animationNumber = 0 this.animationNumber = 0
this.ended = false
} }
} }

View File

@ -1,22 +1,24 @@
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import ColliderDebugger from 'GameEngine/2D/Debug/ColliderDebugger'
import PointDebugger from 'GameEngine/2D/Debug/PointDebugger'
import Vector2D from 'GameEngine/2D/Vector2D'
import Asset from 'GameEngine/Asset'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import TileRenderer from 'GameEngine/Renderer/TileRenderer'
import SoundManager from 'GameEngine/SoundManager'
import Tileset from 'GameEngine/Tileset'
import { globalState } from '.' import { globalState } from '.'
import GameEngine from '../../GameEngine'
import BoxCollider2D from '../../GameEngine/2D/Collider/BoxCollider2D'
import ColliderDebugger from '../../GameEngine/2D/Debug/ColliderDebugger'
import PointDebugger from '../../GameEngine/2D/Debug/PointDebugger'
import Vector2D from '../../GameEngine/2D/Vector2D'
import Asset from '../../GameEngine/Asset'
import Component2D, { type ComponentState } from '../../GameEngine/Component2D'
import Cursor from '../../GameEngine/Components/Cursor'
import TileRenderer from '../../GameEngine/Renderer/TileRenderer'
import SoundManager from '../../GameEngine/SoundManager'
import Tileset from '../../GameEngine/Tileset'
import { Explosion } from './Explosion' import { Explosion } from './Explosion'
export default class Item extends Component2D { export default class Item extends Component2D {
public static explosion = new Explosion() public static explosion = new Explosion()
public collider: BoxCollider2D = new BoxCollider2D(this, 'click') public override collider: BoxCollider2D = new BoxCollider2D(this, {
tags: 'cursor'
})
private x: number private x: number
private y: number private y: number
@ -32,53 +34,58 @@ export default class Item extends Component2D {
this.x = Math.trunc(index % 3) this.x = Math.trunc(index % 3)
this.y = Math.trunc(index / 3) this.y = Math.trunc(index / 3)
this.position = new Vector2D( this.position = new Vector2D(
this.x, this.x + 0.025 * this.x,
this.y this.y + 0.025 * this.y
) )
this.scale = new Vector2D( this.scale = new Vector2D(
.9, .9 .95, .95
) )
// this.renderer = new RectRenderer(this, {material: 'green'}) // this.renderer = new RectRenderer(this, {material: 'green'})
this.renderer = new TileRenderer(this, {tileset: this.tileset, id: -1}) this.renderer = new TileRenderer(this, {tileset: this.tileset, id: -1})
// console.log(this.tileset.getSourceData(0), this.tileset.width(0), this.tileset.height(0)) // console.log(this.tileset.getSourceData(0), this.tileset.width(0), this.tileset.height(0))
// console.log(this.tileset.getSourceData(1)) // console.log(this.tileset.getSourceData(1))
this.childs = [ this.childs = [
new ColliderDebugger(this, this.collider), new ColliderDebugger(),
new PointDebugger(this.collider.pos()[0]), new PointDebugger({
new PointDebugger(this.collider.pos()[1]) point: this.collider.pos()[0],
size: .1
}),
new PointDebugger({
point: this.collider.pos()[1],
color: 'green',
size: .1
})
] ]
// console.log(index) // console.log(index)
} }
public init() { public override async update(state: ComponentState) {
console.log('item initialized')
}
public async update(state: ComponentState) {
if (!globalState.isPlaying) { if (!globalState.isPlaying) {
return return
} }
// console.log(state) // console.log(state)
const value: '' | 'X' | 'O' = globalState.gameState[this.x][this.y] as '' | 'X' | 'O' const cursor = state.collisions?.find((it) => it.component instanceof Cursor)?.component as Cursor | undefined
if (state.isColliding === 'click' && value === '') { const value: '' | 'X' | 'O' = globalState.gameState?.[this.x]?.[this.y] as '' | 'X' | 'O'
if (!cursor?.isDown && cursor?.wasDown) {
// console.log('hovering') // console.log('hovering')
await this.onClick() await this.onClick()
} }
if (value === 'X') { if (value === 'X') {
(this.renderer as TileRenderer).id = 1 this.renderer?.setProps({id: 1})
// this.renderer = new ImageRenderer(this, Asset.init('/assets/tictactoe/X.png')) // this.renderer = new ImageRenderer(this, Asset.init('/assets/tictactoe/X.png'))
} else if(value === 'O') { } else if(value === 'O') {
(this.renderer as TileRenderer).id = 0 this.renderer?.setProps({id: 0})
} else { } else {
(this.renderer as TileRenderer).id = -1 this.renderer?.setProps({id: -1})
} }
} }
private async onClick() { private async onClick() {
console.log(this.position)
const clickSound = new SoundManager('/assets/tictactoe/bip.wav') const clickSound = new SoundManager('/assets/tictactoe/bip.wav')
clickSound.play() clickSound.play()
globalState.gameState[this.x][this.y] = globalState.playerTurn globalState.gameState![this.x]![this.y] = globalState.playerTurn
console.log(this.checkVictory()) console.log(this.checkVictory())
if (this.checkVictory()) { if (this.checkVictory()) {
Item.explosion.run(this.position) Item.explosion.run(this.position)

View File

@ -1,18 +1,15 @@
import Vector2D from 'GameEngine/2D/Vector2D' import Vector2D from '../../GameEngine/2D/Vector2D'
import Component2D from 'GameEngine/Component2D' import Component2D from '../../GameEngine/Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer' import RectRenderer from '../../GameEngine/Renderer/RectRenderer'
export default class Line extends Component2D { export default class Line extends Component2D {
// public debug = true
public constructor(direction: number, index: number) { public constructor(direction: number, index: number) {
super() super()
this.renderer = new RectRenderer(this, {material: 'orange'}) this.renderer = new RectRenderer(this, {material: 'orange'})
this.position = new Vector2D( this.position = new Vector2D(
direction ? index ? 1.5 : 0.5 : 1, direction ? index ? 1.95 : 0.95 : 0,
direction ? 1 : index ? 0.5 : 1.5 direction ? 0 : index ? 1.95 : 0.95
) )
this.scale = new Vector2D( this.scale = new Vector2D(
@ -27,5 +24,4 @@ export default class Line extends Component2D {
// this.childs = [new ComponentDebug(this)] // this.childs = [new ComponentDebug(this)]
} }
} }

View File

@ -1,28 +1,30 @@
import GameEngine from 'GameEngine'
import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D'
import ColliderDebugger from 'GameEngine/2D/Debug/ColliderDebugger'
import Vector2D from 'GameEngine/2D/Vector2D'
import Component2D, { ComponentState } from 'GameEngine/Component2D'
import RectRenderer from 'GameEngine/Renderer/RectRenderer'
import { globalState } from '..' import { globalState } from '..'
import GameEngine from '../../../GameEngine'
import BoxCollider2D from '../../../GameEngine/2D/Collider/BoxCollider2D'
import ColliderDebugger from '../../../GameEngine/2D/Debug/ColliderDebugger'
import Vector2D from '../../../GameEngine/2D/Vector2D'
import Component2D, { type ComponentState } from '../../../GameEngine/Component2D'
import Cursor from '../../../GameEngine/Components/Cursor'
import RectRenderer from '../../../GameEngine/Renderer/RectRenderer'
export default class Start extends Component2D { export default class Start extends Component2D {
public renderer: RectRenderer = new RectRenderer(this, {material: 'yellow'}) public override renderer: RectRenderer = new RectRenderer(this, {material: 'yellow'})
public position: Vector2D = new Vector2D(1, 1) public override position: Vector2D = new Vector2D(0.5, 1)
public scale: Vector2D = new Vector2D(2, 1) public override scale: Vector2D = new Vector2D(2, 1)
public collider: BoxCollider2D = new BoxCollider2D(this, 'click') public override collider: BoxCollider2D = new BoxCollider2D(this, {
public childs: Array<Component2D> = [new ColliderDebugger(this, this.collider)] tags: 'cursor'
})
public override childs: Array<Component2D> = [new ColliderDebugger()]
private hasCollided = false public override async update(state: ComponentState) {
const cursor = (state.collisions?.find((it) => it.component instanceof Cursor))?.component as Cursor | undefined
public async update(state: ComponentState) { if (!cursor) {
if (state.isColliding === 'click') { return
this.hasCollided = true }
} else if (this.hasCollided) { if (cursor.leftBtn.wasDown && !cursor.leftBtn.isDown) {
console.log('Start Game !') console.log('Start Game !')
await GameEngine.getGameEngine().setScene('TicTacToe') await GameEngine.getGameEngine().setScene('TicTacToe')
globalState.isPlaying = true globalState.isPlaying = true
this.hasCollided = false
} }
} }
} }

28
src/layouts/Base.astro Normal file
View File

@ -0,0 +1,28 @@
---
export interface Props {
title: string
}
import Favicon from '../components/Favicon/Favicon.astro'
import IconSVG from '../assets/layouts/Base/favicon.svg'
import IconPNG from '../assets/layouts/Base/favicon.png'
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Astro description">
<meta name="viewport" content="width=device-width" />
<Favicon svg={IconSVG} png={IconPNG} icoPath="/favicon.ico" />
<title>{title}</title>
<!-- Plausible -->
<script defer data-domain="lucide-astro.dzeio.com" src="/js/script.js" />
</head>
<body class="bg-gray-50">
<slot />
</body>
</html>

11
src/layouts/Layout.astro Normal file
View File

@ -0,0 +1,11 @@
---
import Base, { type Props as BaseProps } from './Base.astro'
export interface Props extends BaseProps {}
---
<Base {...Astro.props}>
<main class="container">
<slot />
</main>
</Base>

7
src/layouts/README.md Normal file
View File

@ -0,0 +1,7 @@
# Layouts
Application different layouts they should extends `Base.astro` if added and also pass the parameters of Base.astro to the page
## Base.astro
This is the base file for each path of the application, executed for each paths

5
src/libs/AstroUtils.ts Normal file
View File

@ -0,0 +1,5 @@
export default class AstroUtils {
public static async wrap<T = void>(fn: () => T | Promise<T>) {
return await fn()
}
}

289
src/libs/HTTP/StatusCode.ts Normal file
View File

@ -0,0 +1,289 @@
/**
* HTTP Status code
*
* Following https://developer.mozilla.org/en-US/docs/Web/HTTP/Status an extension of the RFC9110
*/
enum StatusCode {
/****************
* 1xx Requests *
****************/
/**
* This interim response indicates that the client should continue the request or ignore the response if the request is already finished.
*/
CONTINUE = 100,
/**
* This code is sent in response to an Upgrade request header from the client and indicates the protocol the server is switching to.
*/
SWITCHING_PROTOCOLS,
/**
* This code indicates that the server has received and is processing the request, but no response is available yet.
*/
PROCESSING,
/**
* This status code is primarily intended to be used with the Link header, letting the user agent start preloading resources while the server prepares a response.
*/
EARLY_HINTS,
/****************
* 2xx Requests *
****************/
/**
* The request succeeded. The result meaning of "success" depends on the HTTP method:
* - `GET`: The resource has been fetched and transmitted in the message body.
* - `HEAD`: The representation headers are included in the response without any message body.
* - `PUT` or `POST`: The resource describing the result of the action is transmitted in the message body.
* - `TRACE`: The message body contains the request message as received by the server.
*/
OK = 200,
/**
* The request succeeded, and a new resource was created as a result. This is typically the response sent after `POST` requests, or some `PUT` requests.
*/
CREATED,
/**
* The request has been received but not yet acted upon. It is noncommittal, since there is no way in HTTP to later send an asynchronous response indicating the outcome of the request. It is intended for cases where another process or server handles the request, or for batch processing.
*/
ACCEPTED,
/**
* This response code means the returned metadata is not exactly the same as is available from the origin server, but is collected from a local or a third-party copy. This is mostly used for mirrors or backups of another resource. Except for that specific case, the `200 OK` response is preferred to this status.
*/
NON_AUTHORITATIVE_INFORMATION,
/**
* There is no content to send for this request, but the headers may be useful. The user agent may update its cached headers for this resource with the new ones.
*/
NO_CONTENT,
/**
* Tells the user agent to reset the document which sent this request.
*/
RESET_CONTENT,
/**
* This response code is used when the Range header is sent from the client to request only part of a resource.
*/
PARTIAL_CONTENT,
/**
* Conveys information about multiple resources, for situations where multiple status codes might be appropriate.
*/
MULTI_STATUS,
/**
* Used inside a `<dav:propstat>` response element to avoid repeatedly enumerating the internal members of multiple bindings to the same collection.
*/
ALREADY_REPORTED,
/**
* The server has fulfilled a `GET` request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
*/
IM_USED = 226,
/****************
* 3xx Requests *
****************/
/**
* The request has more than one possible response. The user agent or user should choose one of them. (There is no standardized way of choosing one of the responses, but HTML links to the possibilities are recommended so the user can pick.)
*/
MULTIPLE_CHOICES = 300,
/**
* The URL of the requested resource has been changed permanently. The new URL is given in the response.
*/
MOVED_PERMANENTLY,
/**
* This response code means that the URI of requested resource has been changed temporarily. Further changes in the URI might be made in the future. Therefore, this same URI should be used by the client in future requests.
*/
FOUND,
/**
* The server sent this response to direct the client to get the requested resource at another URI with a GET request.
*/
SEE_OTHER,
/**
* This is used for caching purposes. It tells the client that the response has not been modified, so the client can continue to use the same cached version of the response.
*/
NOT_MODIFIED,
/**
* Defined in a previous version of the HTTP specification to indicate that a requested response must be accessed by a proxy. It has been deprecated due to security concerns regarding in-band configuration of a proxy.
*/
USE_PROXY,
/**
* This response code is no longer used; it is just reserved. It was used in a previous version of the HTTP/1.1 specification.
*/
// UNUSED
/**
* The server sends this response to direct the client to get the requested resource at another URI with the same method that was used in the prior request. This has the same semantics as the `302 Found` HTTP response code, with the exception that the user agent must not change the HTTP method used: if a `POST` was used in the first request, a `POST` must be used in the second request.
*/
TEMPORARY_REDIRECT = 307,
/**
* This means that the resource is now permanently located at another URI, specified by the `Location:` HTTP Response header. This has the same semantics as the `301 Moved Permanently` HTTP response code, with the exception that the user agent must not change the HTTP method used: if a `POST` was used in the first request, a `POST` must be used in the second request.
*/
PERMANENT_REDIRECT,
/****************
* 4xx Requests *
****************/
/**
* The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).
*/
BAD_REQUEST = 400,
/**
* Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.
*/
UNAUTHORIZED,
/**
* This response code is reserved for future use. The initial aim for creating this code was using it for digital payment systems, however this status code is used very rarely and no standard convention exists.
*/
PAYMENT_REQUIRED,
/**
* The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike `401 Unauthorized`, the client's identity is known to the server.
*/
FORBIDDEN,
/**
* The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of `403 Forbidden` to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web.
*/
NOT_FOUND,
/**
* The request method is known by the server but is not supported by the target resource. For example, an API may not allow calling `DELETE` to remove a resource.
*/
METHOD_NOT_ALLOWED,
/**
* This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent.
*/
NOT_ACCEPTABLE,
/**
* This is similar to `401 Unauthorized` but authentication is needed to be done by a proxy.
*/
PROXY_AUTHENTIFICATION_REQUIRED,
/**
* This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also note that some servers merely shut down the connection without sending this message.
*/
REQUEST_TIMEOUT,
/**
* This response is sent when a request conflicts with the current state of the server.
*/
CONFLICT,
/**
* This response is sent when the requested content has been permanently deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code.
*/
GONE,
/**
* Server rejected the request because the `Content-Length` header field is not defined and the server requires it.
*/
LENGTH_REQUIRED,
/**
* The client has indicated preconditions in its headers which the server does not meet.
*/
PRECONDITION_FAILED,
/**
* Request entity is larger than limits defined by server. The server might close the connection or return an `Retry-After` header field.
*/
PAYLOAD_TOO_LARGE,
/**
* The URI requested by the client is longer than the server is willing to interpret.
*/
URI_TOO_LONG,
/**
* The media format of the requested data is not supported by the server, so the server is rejecting the request.
*/
UNSUPPORTED_MEDIA_TYPE,
/**
* The range specified by the `Range` header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target URI's data.
*/
RANGE_NOT_SATISFIABLE,
/**
* This response code means the expectation indicated by the `Expect` request header field cannot be met by the server.
*/
EXPECTATION_FAILED,
/**
* The server refuses the attempt to brew coffee with a teapot.
*/
IM_A_TEAPOT,
/**
* The request was directed at a server that is not able to produce a response. This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI.
*/
MIDIRECTED_REQUEST = 421,
/**
* The request was well-formed but was unable to be followed due to semantic errors.
*/
UNPROCESSABLE_CONTENT,
/**
* The resource that is being accessed is locked.
*/
LOCKED,
/**
* The request failed due to failure of a previous request.
*/
FAILED_DEPENDENCY,
/**
* Indicates that the server is unwilling to risk processing a request that might be replayed.
*/
TOO_EARLY,
/**
* The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. The server sends an `Upgrade` header in a 426 response to indicate the required protocol(s).
*/
UPGRADE_REQUIRED,
/**
* The origin server requires the request to be conditional. This response is intended to prevent the 'lost update' problem, where a client `GET`s a resource's state, modifies it and `PUT`s it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.
*/
PRECONDITION_REQUIRED = 428,
/**
* The user has sent too many requests in a given amount of time ("rate limiting").
*/
TOO_MANY_REQUESTS,
/**
* The server is unwilling to process the request because its header fields are too large. The request may be resubmitted after reducing the size of the request header fields.
*/
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
/**
* The user agent requested a resource that cannot legally be provided, such as a web page censored by a government.
*/
UNAVAILABLE_OR_LEGAL_REASONS = 451,
/****************
* 5xx Requests *
****************/
/**
* The server has encountered a situation it does not know how to handle.
*/
INTERNAL_SERVER_ERROR = 500,
/**
* The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are `GET` and `HEAD`.
*/
NOT_IMPLEMENTED,
/**
* This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.
*/
BAD_GATEWAY,
/**
* The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the `Retry-After` HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached.
*/
SERVICE_UNAVAILABLE,
/**
* This error response is given when the server is acting as a gateway and cannot get a response in time.
*/
GATEWAY_TIMEOUT,
/**
* The HTTP version used in the request is not supported by the server.
*/
HTTP_VERSION_NOT_SUPPORTED,
/**
* The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process.
*/
VARIANT_ALSO_NEGOTIATES,
/**
* The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.
*/
INSUFFICIENT_STORAGE,
/**
* The server detected an infinite loop while processing the request.
*/
LOOP_DETECTED,
/**
* Further extensions to the request are required for the server to fulfill it.
*/
NOT_EXTENDED = 510,
/**
* Indicates that the client needs to authenticate to gain network access.
*/
NETWORK_AUTHENTIFICATION_REQUIRED,
}
export default StatusCode

3
src/libs/README.md Normal file
View File

@ -0,0 +1,3 @@
# Libs
Globally independent objects/classes/functions that SHOULD be unit testable by themselve

66
src/libs/RFCs/RFC7807.ts Normal file
View File

@ -0,0 +1,66 @@
import ResponseBuilder from '../ResponseBuilder'
/**
* Add headers:
* Content-Type: application/problem+json
*
* following https://www.rfc-editor.org/rfc/rfc7807.html
*/
export default interface RFC7807 {
/**
* A URI reference [RFC3986] that identifies the
* problem type.
*
* This specification encourages that, when
* dereferenced, it provide human-readable documentation for the
* problem type (e.g., using HTML [W3C.REC-html5-20141028]).
*
* When
* this member is not present, its value is assumed to be
* "about:blank"
*/
type?: string
/**
* A short, human-readable summary of the problem
* type.
*
* It SHOULD NOT change from occurrence to occurrence of the
* problem, except for purposes of localization (e.g., using
* proactive content negotiation; see [RFC7231], Section 3.4).
*/
title?: string
/**
* The HTTP status code ([RFC7231], Section 6)
* generated by the origin server for this occurrence of the problem.
*/
status?: number
/**
* A human-readable explanation specific to this
* occurrence of the problem.
*/
details?: string
/**
* A URI reference that identifies the specific
* occurrence of the problem.
*
* It may or may not yield further
* information if dereferenced.
*/
instance?: string
}
/**
*
* @param error the error (base items are type, status, title details and instance)
* @returns
*/
export function buildRFC7807(error: RFC7807 & Record<string, any>, response: ResponseBuilder = new ResponseBuilder()): Response {
response.addHeader('Content-Type', 'application/problem+json')
.body(JSON.stringify(error))
.status(error.status ?? 500)
return response.build()
}

View File

@ -0,0 +1,59 @@
import { objectLoop } from '@dzeio/object-util'
/**
* Simple builde to create a new Response object
*/
export default class ResponseBuilder {
private _body: BodyInit | null | undefined
public body(body: string | Buffer | object | null | undefined) {
if (typeof body === 'object' && !(body instanceof Buffer)) {
this._body = JSON.stringify(body)
this.addHeader('Content-Type', 'application/json')
} else if (body instanceof Buffer) {
this._body = body.toString()
} else {
this._body = body
}
return this
}
private _headers: Record<string, string> = {}
public headers(headers: Record<string, string>) {
this._headers = headers
return this
}
public addHeader(key: string, value: string) {
this._headers[key] = value
return this
}
public addHeaders(headers: Record<string, string>) {
objectLoop(headers, (value, key) => {
this.addHeader(key, value)
})
return this
}
public removeHeader(key: string) {
delete this._headers[key]
return this
}
private _status?: number
public status(status: number) {
this._status = status
return this
}
public build(): Response {
const init: ResponseInit = {
headers: this._headers
}
if (this._status) {
init.status = this._status
}
return new Response(this._body, init)
}
}

71
src/libs/States.ts Normal file
View File

@ -0,0 +1,71 @@
import { isObject, objectClone, objectEqual } from '@dzeio/object-util'
type ChangeFn<T> = (props: T, oldProps: T) => void | Promise<void>
export default class States<T extends Record<string, any>> {
private observers: Partial<Record<keyof T, Array<ChangeFn<Partial<T>>>>> = {}
public props: T = {} as T
public setProps(items: Partial<T>, options?: {
force: boolean
}) {
const old = objectClone(this.props)
let updated = false
for (const item in items) {
if (
!options?.force &&
((isObject(this.props[item]) && isObject(items[item]) && objectEqual(this.props[item] as object, items[item] as object)) ||
this.props[item] === items[item])
) {
console.warn(item, 'does not need to be updated')
continue
}
updated = true
this.props[item] = items[item] as T[typeof item]
const obs = this.observers[item]
if (obs) {
for (const observer of obs) {
observer(this.props, old)
}
}
}
if (updated) {
const obs = this.observers['']
if (obs) {
for (const observer of obs) {
observer(this.props, old)
}
}
}
}
public constructor(
props: T
) {
this.setProps(props)
}
public forceUpdate(key: string) {
const obs = this.observers[key]
if (obs) {
for (const observer of obs) {
observer(this.props, this.props)
}
}
}
public onUpdate(fn: ChangeFn<Partial<T>>) {
return this.listen('', fn)
}
public listen(key: keyof T | '', fn: ChangeFn<Partial<T>>) {
if (!this.observers[key]) {
this.observers[key] = []
}
this.observers[key]?.push(fn)
if (typeof this.props[key] !== 'undefined') {
fn(this.props, this.props)
}
}
}

103
src/models/Dao.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* the Dao is the object that connect the Database or source to the application layer
*
* you MUST call it through the `DaoFactory` file
*/
export default abstract class Dao<Object extends { id: any } = { id: any }> {
/**
* insert a new object into the source
*
* @param obj the object to create
* @returns the object with it's id filled if create or null otherwise
*/
abstract create(obj: Omit<Object, 'id'>): Promise<Object | null>
/**
* insert a new object into the source
*
* @param obj the object to create
* @returns the object with it's id filled if create or null otherwise
*/
public insert: Dao<Object>['create'] = (obj: Parameters<Dao<Object>['create']>[0]) => this.create(obj)
/**
* find the list of objects having elements from the query
*
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns an array containing the list of elements that match with the query
*/
abstract findAll(query?: Partial<Object>): Promise<Array<Object>>
/**
* find the list of objects having elements from the query
*
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns an array containing the list of elements that match with the query
*/
public find: Dao<Object>['findAll'] = (query: Parameters<Dao<Object>['findAll']>[0]) => this.findAll(query)
/**
* find an object by it's id
*
* (shortcut to findOne({id: id}))
*
* @param id the id of the object
* @returns
*/
public findById(id: Object['id']): Promise<Object | null> {
return this.findOne({id: id} as Partial<Object>)
}
/**
* find an object by it's id
*
* (shortcut to findOne({id: id}))
*
* @param id the id of the object
* @returns
*/
public get(id: Object['id']) {
return this.findById(id)
}
/**
* find the first element that match `query`
*
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns the first element matching with the query or null otherwise
*/
public async findOne(query?: Partial<Object>): Promise<Object | null> {
return (await this.findAll(query))[0] ?? null
}
/**
* update the remote reference of the object
*
* note: it will not try to insert an item (use `upsert` to handle this)
*
* @param obj the object to update
* @returns an object if it was able to update or null otherwise
*/
abstract update(obj: Object): Promise<Object | null>
/**
* update the remote reference of the object or create it if not found
* @param obj the object to update/insert
* @returns the object is updated/inserted or null otherwise
*/
public async upsert(object: Object | Omit<Object, 'id'>): Promise<Object | null> {
if ('id' in object) {
return this.update(object)
}
return this.insert(object)
}
/**
* Delete the object
* @param obj the object to delete
*
* @returns if the object was deleted or not (if object is not in db it will return true)
*/
abstract delete(obj: Object): Promise<boolean>
}

54
src/models/DaoFactory.ts Normal file
View File

@ -0,0 +1,54 @@
/**
* TODO:
* Add to `DaoItem` your model name
* Add to the function `initDao` the Dao
*/
/**
* the different Daos that can be initialized
*
* Touch this interface to define which key is linked to which Dao
*/
interface DaoItem {
}
/**
* Class to get any DAO
*/
export default class DaoFactory {
/**
* reference of the different Daos for a correct singleton implementation
*/
private static daos: Partial<DaoItem> = {}
/**
* Get a a dao by its key
*
* it will throw an error if no Dao exists linked to the item key
*
* @param key the dao key to get
* @returns the Dao you want as a singleton
*/
public static get<Key extends keyof DaoItem>(key: Key): DaoItem[Key] {
if (!(key in this.daos)) {
const dao = this.initDao(key)
if (!dao) {
throw new Error(`${key} has no valid Dao`)
}
this.daos[key] = dao as DaoItem[Key]
}
return this.daos[key] as DaoItem[Key]
}
/**
* init a dao by its key, it does not care if it exists or not
*
* @param item the element to init
* @returns a new initialized dao or undefined if no dao is linked
*/
private static initDao(item: keyof DaoItem): any | undefined {
switch (item) {
default: return undefined
}
}
}

26
src/models/README.md Normal file
View File

@ -0,0 +1,26 @@
# Models
this folder contains the Application data layer
## Workflow
1. Add a {model}/index.ts contianing your `interface`
2. Add a {model}/{model}Dao.ts containing your DAO that `extends` from `Dao.ts`
3. Add your Dao to the `DaoFactory.ts` file
## **/index.ts
file containing the definition of the model
## **/\*Dao.ts
File containing the implementation of the Dao
## Dao.ts
the Dao.ts is the file each `*Dao.ts` extends from allowing to have a simple, quick and easy to comprehend connectivity
## DaoFactory.ts
The DaoFactory file is the file in which you will have the only direct reference to each `*Dao` files and will be sent from there to the rest of the applicaiton layer

5
src/pages/README.md Normal file
View File

@ -0,0 +1,5 @@
# Content
Contains raw content for pages.
Mostly some static pages or blog posts.

View File

@ -1,22 +0,0 @@
import React from 'react'
import App from 'next/app'
import PlausibleProvider from 'next-plausible'
export default class CApp extends App {
public render() {
const { Component, pageProps } = this.props
return (
<PlausibleProvider
enabled
customDomain="https://proxy.dzeio.com"
domain="games.avior.me"
integrity="sha256-R6vN8jmBq9SIpnfJRnw9eNUfLbC2yO3GPQAKR5ZS7zQ="
>
<Component {...pageProps} />
</PlausibleProvider>
)
}
}

View File

@ -1,19 +0,0 @@
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
public render() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument

22
src/pages/api/event.ts Normal file
View File

@ -0,0 +1,22 @@
import type { APIRoute } from 'astro'
/**
* Plausible proxy
*/
export const post: APIRoute = async ({ request, clientAddress }) => {
// const body = await request.json()
// console.log(body, clientAddress)
const res = await fetch('https://plausible.io/api/event', {
method: 'POST',
headers: {
'User-Agent': request.headers.get('User-Agent') as string,
'X-Forwarded-For': clientAddress,
'Content-Type': 'application/json'
},
body: await request.text()
})
return {
status: res.status,
body: await res.text()
}
}

View File

@ -1,89 +0,0 @@
/* eslint-disable max-classes-per-file */
import React from 'react'
import GameEngine from 'GameEngine'
import Scene from 'GameEngine/Scene'
import Space from 'games/city/Space'
import Vector2D from 'GameEngine/2D/Vector2D'
import Cursor from 'games/city/Cursor'
import Button from 'games/city/Button'
import Text from 'games/city/Text'
import FPSCounter from 'GameEngine/Components/FPSCounter'
export default class Snake extends React.PureComponent {
public async componentDidMount() {
const ge = new GameEngine('#test', {
caseCount: [208,104],
debugColliders: false,
goalFramerate: 60
})
const mainScene = new Scene('Menu')
mainScene.camera.topLeft.x = 0
mainScene.camera.topLeft.y = 0
const cursor = new Cursor()
// for (let ch = 0; ch < 104; ch++) {
// for (let cw = 0; cw < 208; cw++) {
// mainScene.addComponent(
// new Tile(cw, ch)
// )
// }
// }
const placeableRects: Array<[Vector2D, Vector2D]> = [
// top rect
[new Vector2D(0, 0), new Vector2D(208, 18)],
// left recet
[new Vector2D(20, 28), new Vector2D(167, 79)],
// right rect
[new Vector2D(178,28), new Vector2D(208, 79)],
// bottom rect
[new Vector2D(0, 89), new Vector2D(208, 104)],
]
for (let i = 0; i < 10; i++) {
const width = i % 2 === 0 ? 20 : 10
const height = i % 2 === 0 ? 10 : 20
const baseX = width / 2 + 1
const baseY = i % 2 === 0 ? 50 : 70
const it = new Space(new Vector2D(baseX, baseY), new Vector2D(width, height), cursor, placeableRects)
// it.debug = i === 0
mainScene.addComponent(
it
)
}
// mainScene.addComponent(
// // cursor,
// new Space(new Vector2D(20, 10), cursor, placeableRects, true)
// )
mainScene.addComponent(
new Button(),
new Text('x10Moved', undefined, 16),
new Text('x20Moved', undefined, 16),
new FPSCounter({textColor: 'white', size: 32})
)
await ge.setScene(mainScene)
ge.start()
}
public render = () => (
<>
<canvas id="test" width="2080" height="1040" style={{backgroundImage: 'url(\'/assets/city/background.png\')'}}></canvas>
</>
)
private randomInt(min: number, max: number): number {
min = Math.ceil(min)
max = Math.floor(max)
return Math.floor(Math.random() * (max - min + 1)) + min
}
}

8
src/pages/index.astro Normal file
View File

@ -0,0 +1,8 @@
---
import Layout from '../layouts/Layout.astro'
---
<Layout title="Welcome to Astro.">
<main>
</main>
</Layout>

View File

@ -1,9 +0,0 @@
import React from 'react'
export default class Index extends React.Component {
public render = () => (
<main>
</main>
)
}

12
src/pages/js/script.js.ts Normal file
View File

@ -0,0 +1,12 @@
import type { APIRoute } from 'astro'
/**
* Plausible proxy
*/
export const get: APIRoute = async () => {
const res = await fetch('https://plausible.io/js/script.file-downloads.outbound-links.js')
return {
status: 200,
body: await res.text()
}
}

Some files were not shown because too many files have changed in this diff Show More