diff --git a/.env.example b/.env.example index a2a1af0..6374512 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ GRAPH_PORT=3000 ELK_PORT=5601 -GOOGLE_CALLBACK_URL=https://localhost:8443/api/v1 # the url to which the user will be redirected when it logs with google CALLBACK_REDIR=http://localhost:3000 GOOGLE_CLIENT_SECRET=susAF @@ -27,5 +26,10 @@ SMTP_AUTH_USERNAME= SMTP_AUTH_PASSWORD= EMAIL_TO= -USER_URL= -AUTH_URL= +USER_URL= +AUTH_URL= +SCORE_URL= + +CORS_ORIGIN= +VITE_USER_API= +VITE_AUTH_API= diff --git a/docker/api-base/compose.yml b/docker/api-base/compose.yml index 76b987e..bed06c8 100644 --- a/docker/api-base/compose.yml +++ b/docker/api-base/compose.yml @@ -15,6 +15,7 @@ services: - API_TARGET=user - LOG_FILE_PATH=/var/log/log.log - JWT_SECRET=${JWT_SECRET} + - CORS_ORIGIN=${CORS_ORIGIN} restart: unless-stopped auth-api: container_name: transcendence-api-auth @@ -29,12 +30,13 @@ services: - back environment: - TZ=Europe/Paris - - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL} + - GOOGLE_CALLBACK_URL=${AUTH_URL} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - API_TARGET=auth - LOG_FILE_PATH=/var/log/log.log - JWT_SECRET=${JWT_SECRET} + - CORS_ORIGIN=${CORS_ORIGIN} restart: unless-stopped scorestore-api: container_name: transcendence-api-scoreStore diff --git a/docker/front/Dockerfile b/docker/front/Dockerfile index b8f2ce2..36fc0e5 100644 --- a/docker/front/Dockerfile +++ b/docker/front/Dockerfile @@ -13,6 +13,9 @@ RUN cd /build \ FROM node:lts-alpine AS builder-vite +ARG VITE_USER_URL +ARG VITE_AUTH_URL + RUN npm install -g pnpm WORKDIR /app @@ -24,8 +27,8 @@ RUN pnpm install --frozen-lockfile COPY vite.config.js tailwind.config.js ./ COPY src ./src -RUN pnpm vite build - +RUN VITE_USER_URL=${VITE_USER_URL} VITE_AUTH_URL=${VITE_AUTH_URL}\ + pnpm vite build FROM alpine:3.22 diff --git a/docker/front/compose.yml b/docker/front/compose.yml index 72b2b67..a85d142 100644 --- a/docker/front/compose.yml +++ b/docker/front/compose.yml @@ -4,6 +4,9 @@ services: build: dockerfile: docker/front/Dockerfile context: ../../ + args: + - VITE_USER_URL=${VITE_USER_URL} + - VITE_AUTH_URL=${VITE_AUTH_URL} environment: - TZ=Europe/Paris networks: diff --git a/flake.lock b/flake.lock index 782aaad..dade030 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1753250450, - "narHash": "sha256-i+CQV2rPmP8wHxj0aq4siYyohHwVlsh40kV89f3nw1s=", + "lastModified": 1756542300, + "narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fc02ee70efb805d3b2865908a13ddd4474557ecf", + "rev": "d7600c775f877cd87b4f5a831c28aa94137377aa", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b61cb1a..fb52ec3 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,7 @@ nodejs_22 pnpm just - foundry + foundry ]; shellHook = '' if [ ! -d node_modules/ ]; then @@ -50,6 +50,7 @@ echo Installing foundry env forge i fi + alias jarvis=just export PATH+=:$(pwd)/node_modules/.bin echo entering ft_trans env ''; diff --git a/package.json b/package.json index 797d338..77c60ce 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "dependencies": { "@avalabs/avalanchejs": "^5.0.0", "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.1.0", "@fastify/env": "^5.0.2", "@fastify/jwt": "^9.1.0", "axios": "^1.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a1bb9..ce70826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 + '@fastify/cors': + specifier: ^11.1.0 + version: 11.1.0 '@fastify/env': specifier: ^5.0.2 version: 5.0.2 @@ -261,6 +264,9 @@ packages: '@fastify/cookie@11.0.2': resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.1.0': + resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} + '@fastify/deepmerge@2.0.2': resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==} @@ -1903,6 +1909,11 @@ snapshots: cookie: 1.0.2 fastify-plugin: 5.0.1 + '@fastify/cors@11.1.0': + dependencies: + fastify-plugin: 5.0.1 + toad-cache: 3.7.0 + '@fastify/deepmerge@2.0.2': {} '@fastify/env@5.0.2': diff --git a/src/api/user/default.js b/src/api/user/default.js index 13d81b3..0057207 100644 --- a/src/api/user/default.js +++ b/src/api/user/default.js @@ -1,5 +1,6 @@ import fastifyJWT from '@fastify/jwt'; import fastifyCookie from '@fastify/cookie'; +import cors from '@fastify/cors' import Database from 'better-sqlite3'; import cors from '@fastify/cors'; @@ -148,6 +149,13 @@ const querySchemaMatchHistoryGame = { type: 'object', required: ['game'], proper * @param {import('fastify').FastifyPluginOptions} options */ export default async function(fastify, options) { + + fastify.register(cors, { + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, + methods: [ "GET", "POST", "PATCH", "DELETE", "OPTIONS" ] + }); + fastify.register(fastifyJWT, { secret: process.env.JWT_SECRET || '123456789101112131415161718192021', cookie: { diff --git a/src/front/index.html b/src/front/index.html index f2f5a6a..dbd12eb 100644 --- a/src/front/index.html +++ b/src/front/index.html @@ -1,19 +1,57 @@ - - - - Vite + Tailwind Test - - - -
-

Vite + Tailwind

-

🚀 Looks like it's working!

- -
- - + + + + + Vite + Tailwind Test + + + + + + + + + + +
+
+ +
+
+ +
+ +
+ + + + diff --git a/src/front/static/assets/favicon.ico b/src/front/static/assets/favicon.ico new file mode 100644 index 0000000..18945be Binary files /dev/null and b/src/front/static/assets/favicon.ico differ diff --git a/src/front/static/assets/fonts/Kubasta.otf b/src/front/static/assets/fonts/Kubasta.otf new file mode 100644 index 0000000..06001b2 Binary files /dev/null and b/src/front/static/assets/fonts/Kubasta.otf differ diff --git a/src/front/static/assets/fonts/Kubasta.ttf b/src/front/static/assets/fonts/Kubasta.ttf new file mode 100644 index 0000000..18f5b82 Binary files /dev/null and b/src/front/static/assets/fonts/Kubasta.ttf differ diff --git a/src/front/static/assets/pong.svg b/src/front/static/assets/pong.svg new file mode 100644 index 0000000..6da6f5c --- /dev/null +++ b/src/front/static/assets/pong.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/front/static/assets/tetrio.svg b/src/front/static/assets/tetrio.svg new file mode 100644 index 0000000..7965017 --- /dev/null +++ b/src/front/static/assets/tetrio.svg @@ -0,0 +1,114 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/src/front/static/css/style.css b/src/front/static/css/style.css new file mode 100644 index 0000000..65bae17 --- /dev/null +++ b/src/front/static/css/style.css @@ -0,0 +1,88 @@ +@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&display=swap'); +@import "tailwindcss"; +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} + +@font-face { + font-family: Kubasta; + src: url('../assets/fonts/Kubasta.otf') format("opentype"); +} + +@source inline("space-y-{18,46,102,214,438,886,1782,3574,7158,14326,28662,57334,114678,229366,458742,917494}"); +@source inline("mt-{28,56,84,112}"); + +@theme { + --shadow-2x1: 2px 2px 0px black; + /* + --color-kanel-700: #ac5c24; + */ +} + +.default-border { + @apply border-2 + border-t-neutral-100 border-l-neutral-100 border-r-neutral-400 border-b-neutral-400 + dark:border-t-neutral-500 dark:border-l-neutral-500 dark:border-r-neutral-700 dark:border-b-neutral-700 + ; +} + +.reverse-border { + @apply border-2 + border-t-neutral-400 border-l-neutral-400 border-r-neutral-100 border-b-neutral-100 + dark:border-t-neutral-700 dark:border-l-neutral-700 dark:border-r-neutral-500 dark:border-b-neutral-500 + ; +} + +.input-border { + @apply border-2 + border-t-neutral-950 border-l-neutral-950 border-r-neutral-200 border-b-neutral-200 + dark:border-t-neutral-950 dark:border-l-neutral-950 dark:border-r-neutral-600 dark:border-b-neutral-600 + ; +} + +.default-button { + @apply shadow-2x1 + bg-neutral-200 hover:bg-neutral-300 active:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-700 + text-neutral-900 dark:text-white + px-4 py-2 + delay-0 duration-150 transition-colors + + border-2 border-t-neutral-100 dark:border-t-neutral-500 border-l-neutral-100 dark:border-l-neutral-500 border-r-neutral-400 dark:border-r-neutral-700 border-b-neutral-400 dark:border-b-neutral-700 + active:border-t-neutral-400 dark:active:border-t-neutral-700 active:border-l-neutral-400 dark:active:border-l-neutral-700 active:border-r-neutral-100 dark:active:border-r-neutral-500 active:border-b-neutral-100 dark:active:border-b-neutral-500 + ; +} + +.taskbar-button { + @apply shadow-2x1 + bg-neutral-200 hover:bg-neutral-300 active:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-700 + text-neutral-900 dark:text-white + px-4 py-0.5 + content-center text-center + delay-0 duration-150 transition-colors + + border-2 border-t-neutral-100 dark:border-t-neutral-500 border-l-neutral-100 dark:border-l-neutral-500 border-r-neutral-400 dark:border-r-neutral-700 border-b-neutral-400 dark:border-b-neutral-700 + active:border-t-neutral-400 dark:active:border-t-neutral-700 active:border-l-neutral-400 dark:active:border-l-neutral-700 active:border-r-neutral-100 dark:active:border-r-neutral-500 active:border-b-neutral-100 dark:active:border-b-neutral-500 + ; +} + +.menu-default-button { + @apply w-46 h-12 + text-neutral-900 dark:text-white + bg-neutral-200 hover:bg-neutral-300 + dark:bg-neutral-800 dark:hover:bg-neutral-700 + ; +} + +.menu-default-label { + @apply w-46 h-12 + text-neutral-900 dark:text-white + bg-neutral-200 + dark:bg-neutral-800 + ; +} diff --git a/src/front/static/ts/main.ts b/src/front/static/ts/main.ts new file mode 100644 index 0000000..35338a5 --- /dev/null +++ b/src/front/static/ts/main.ts @@ -0,0 +1,174 @@ +import { oneko } from "./oneko.ts"; +import ProfileMenu from "./views/ProfileMenu.ts"; +import FriendsMenu from "./views/Friends.ts"; +let profile_view = new ProfileMenu; +let friends_view = new FriendsMenu; + +export const user_api = import.meta.env.VITE_USER_API as String; +export const auth_api = import.meta.env.VITE_AUTH_API as String; + +export async function isLogged(): Promise { + let uuid_req = await fetch(auth_api + "/me", { + method: "GET", + credentials: "include", + }); + if (uuid_req.status === 200) { + let uuid = await uuid_req.json(); + document.cookie = `uuid=${uuid.user};max-age=${60 * 60 * 24 * 7}`; + if (!document.getElementById("friends-btn")) + { + const btn: HTMLButtonElement = document.createElement("button") as HTMLButtonElement; + btn.id = "friends-btn"; + btn?.classList.add("taskbar-button"); + btn.innerText = "friends"; + document.getElementById("taskbar-trail")?.prepend(btn); + } + return true; + } + else // 401 + { + document.cookie = `uuid=;max-age=0`; + const btn = document.getElementById("friends-btn") as HTMLButtonElement; + if (btn) btn.remove(); + return false; + } +} + +export const navigationManager = url => { + history.pushState(null, null, url); + router(); +}; + +let view; + +const routes = [ + { path: "/", view: () => import("./views/MainMenu.ts") }, + + { path: "/pong", view: () => import("./views/PongMenu.ts") }, + { path: "/pong/local", view: () => import("./views/Pong.ts") }, + { path: "/pong/tournament", view: () => import("./views/TournamentMenu.ts") }, + + { path: "/tetris", view: () => import("./views/TetrisMenu.ts") }, + { path: "/tetris/solo", view: () => import("./views/Tetris.ts") }, + { path: "/tetris/versus", view: () => import("./views/TetrisVersus.ts") }, + + { path: "/login", view: () => import("./views/LoginPage.ts") }, + { path: "/register", view: () => import("./views/RegisterPage.ts") }, + + { path: "/profile", view: () => import("./views/Profile.ts") }, + { path: "/settings", view: () => import("./views/Settings.ts") }, +]; + +const router = async () => { + + const routesMap = routes.map(route => { + return { route: route, isMatch: location.pathname === route.path }; + }); + + let match = routesMap.find(routeMap => routeMap.isMatch); + + if (!match) + match = { route: routes[0], isMatch: true }; + + if (view) + view.running = false; + + //console.log(match); + + const module = await match.route.view(); + view = new module.default(); + + document.querySelector("#app").innerHTML = await view.getHTML(); + view.run(); +}; + +document.getElementById("profile-button")?.addEventListener("click", () => { profile_view.run(); }); +window.addEventListener("popstate", router); + +document.addEventListener("DOMContentLoaded", () => { + isLogged(); + + document.body.addEventListener("click", e => { + if (!e.target.closest("#taskbar-menu") && !e.target.matches("#profile-button")) { + profile_view.open = false; + document.getElementById("taskbar-menu").innerHTML = ""; + } + if (e.target.matches("#friends-btn")) { + friends_view.open = !friends_view.open; + friends_view.run(); + } + if (e.target.matches("[data-link]")) { + e.preventDefault(); + navigationManager(e.target.href); + } + if (e.target.closest("[data-icon]")) + e.preventDefault(); + }); + + document.body.addEventListener("dblclick", e => { + if (e.target.closest("[data-icon]")) { + e.preventDefault(); + navigationManager(e.target.closest("[data-icon]").href); + } + }); + + router(); +}); + +function updateClock() +{ + const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const clock = document.getElementById("taskbar-clock"); + const now = new Date(); + let hours = now.getHours(); + let minutes = now.getMinutes(); + + hours = hours < 10 ? "0" + hours : hours; + minutes = minutes < 10 ? "0" + minutes : minutes; + + clock.innerHTML = `${days[now.getDay()]} ${now.getDate()} ${months[now.getMonth()]} ` + hours + ":" + minutes; +} + +async function pingClock() { + if (await isLogged()) { + fetch(user_api + "/ping", { + method: "POST", + credentials: "include" + }); + } +} + +setInterval(updateClock, 5000); +updateClock(); + +setInterval(pingClock, 30000); + +oneko(); + +async function startMenuPP() { + const profileButton = document.getElementById("start-img") as HTMLImageElement; + try { + if(document.cookie.match(new RegExp('(^| )' + "token" + '=([^;]+)'))) { + throw "not today, thank you"; + } + let uuid: String; + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + + + const a = await fetch(`http://localhost:3002/users/${uuid}/avatar`, { + method: "GET", + credentials: "include" + }); + + profileButton.src = a.status === 200 + ? `http://localhost:3002/users/${uuid}/avatar?t=${Date.now()}` + : "https://api.kanel.ovh/pp"; + } catch (err){ + // console.log("not yet logged, going default for start icon..."); + profileButton.src = "https://api.kanel.ovh/id?id=65"; + } +} + +setInterval(startMenuPP, 3000); +startMenuPP(); diff --git a/src/front/static/ts/oneko.ts b/src/front/static/ts/oneko.ts new file mode 100644 index 0000000..01d3453 --- /dev/null +++ b/src/front/static/ts/oneko.ts @@ -0,0 +1,284 @@ +// oneko.js: https://github.com/adryd325/oneko.js +// edited by yosyo specificely for knl_meowscendence. + +let oneko_state: number = 0; // 0 = normal, 1 = pong, 2 = tetris +let mousePosX: number = 0; +let mousePosY: number = 0; +let offsetX: number = 0; +let offsetY: number = 0; + +export function setOnekoState(state: string) { + switch (state) { + case "pong": + oneko_state = 1; + break; + case "tetris": + oneko_state = 2; + break; + default: + oneko_state = 0; + } + return; +} + +export function setOnekoOffset() { + if (oneko_state === 1) + { + offsetX = document.getElementById("window").offsetLeft + 44; + offsetY = document.getElementById("window").offsetTop + 44 + 24; + } + return; +} + +export function setSleepPos() { + mousePosX = document.getElementById("window")?.offsetLeft + 120; + mousePosY = document.getElementById("window")?.offsetTop + 400; +} + +export function setBallPos(x: number, y: number) +{ + mousePosX = x + offsetX; + mousePosY = y + offsetY; + return; +} + +export function oneko() { + const isReducedMotion = + window.matchMedia(`(prefers-reduced-motion: reduce)`) === true || + window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true; + + if (isReducedMotion) return ; + + const nekoEl = document.createElement("div"); + + let nekoPosX = 256; + let nekoPosY = 256; + + let frameCount = 0; + let idleTime = 0; + let idleAnimation = null; + let idleAnimationFrame = 0; + + const nekoSpeed = 10; + const spriteSets = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], + }; + + function init() { + nekoEl.id = "oneko"; + nekoEl.ariaHidden = true; + nekoEl.style.width = "32px"; + nekoEl.style.height = "32px"; + nekoEl.style.position = "fixed"; + nekoEl.style.pointerEvents = "none"; + nekoEl.style.imageRendering = "pixelated"; + nekoEl.style.left = `${nekoPosX - 16}px`; + nekoEl.style.top = `${nekoPosY - 16}px`; + nekoEl.style.zIndex = 2147483647; + + let nekoFile = "https://kanel.ovh/assets/oneko.gif" + const curScript = document.currentScript + if (curScript && curScript.dataset.cat) { + nekoFile = curScript.dataset.cat + } + nekoEl.style.backgroundImage = `url(${nekoFile})`; + + document.body.appendChild(nekoEl); + + document.addEventListener("mousemove", function (event) { + if (oneko_state == 0) + { + mousePosX = event.clientX; + mousePosY = event.clientY; + } + }); + + window.requestAnimationFrame(onAnimationFrame); + } + + let lastFrameTimestamp: number; + + function onAnimationFrame(timestamp: number) { + // Stops execution if the neko element is removed from DOM + if (!nekoEl.isConnected) { + return; + } + if (!lastFrameTimestamp) { + lastFrameTimestamp = timestamp; + } + if (timestamp - lastFrameTimestamp > 100) { + lastFrameTimestamp = timestamp + frame() + } + window.requestAnimationFrame(onAnimationFrame); + } + + function setSprite(name, frame) { + const sprite = spriteSets[name][frame % spriteSets[name].length]; + nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; + } + + function resetIdleAnimation() { + idleAnimation = null; + idleAnimationFrame = 0; + } + + function idle() { + idleTime += 1; + + // every ~ 20 seconds + if (oneko_state === 2) { + idleAnimation = "sleeping"; + } + else if ( + idleTime > 10 && + Math.floor(Math.random() * 200) == 0 && + idleAnimation == null + ) { + let avalibleIdleAnimations = ["sleeping", "scratchSelf"]; + if (nekoPosX < 32) { + avalibleIdleAnimations.push("scratchWallW"); + } + if (nekoPosY < 32) { + avalibleIdleAnimations.push("scratchWallN"); + } + if (nekoPosX > window.innerWidth - 32) { + avalibleIdleAnimations.push("scratchWallE"); + } + if (nekoPosY > window.innerHeight - 32) { + avalibleIdleAnimations.push("scratchWallS"); + } + idleAnimation = + avalibleIdleAnimations[ + Math.floor(Math.random() * avalibleIdleAnimations.length) + ]; + } + + switch (idleAnimation) { + case "sleeping": + if (idleAnimationFrame < 8) { + setSprite("tired", 0); + break; + } + setSprite("sleeping", Math.floor(idleAnimationFrame / 4)); + if (idleAnimationFrame > 192) { + resetIdleAnimation(); + } + break; + case "scratchWallN": + case "scratchWallS": + case "scratchWallE": + case "scratchWallW": + case "scratchSelf": + setSprite(idleAnimation, idleAnimationFrame); + if (idleAnimationFrame > 9) { + resetIdleAnimation(); + } + break; + default: + setSprite("idle", 0); + return; + } + idleAnimationFrame += 1; + } + + function frame() { + frameCount += 1; + const diffX = nekoPosX - mousePosX; + const diffY = nekoPosY - mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if (distance < nekoSpeed || distance < 48) { + idle(); + return; + } + + idleAnimation = null; + idleAnimationFrame = 0; + + if (idleTime > 1) { + setSprite("alert", 0); + // count down after being alerted before moving + idleTime = Math.min(idleTime, 7); + idleTime -= 1; + return; + } + + let direction; + direction = diffY / distance > 0.5 ? "N" : ""; + direction += diffY / distance < -0.5 ? "S" : ""; + direction += diffX / distance > 0.5 ? "W" : ""; + direction += diffX / distance < -0.5 ? "E" : ""; + setSprite(direction, frameCount); + + nekoPosX -= (diffX / distance) * nekoSpeed; + nekoPosY -= (diffY / distance) * nekoSpeed; + + nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16); + nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16); + + nekoEl.style.left = `${nekoPosX - 16}px`; + nekoEl.style.top = `${nekoPosY - 16}px`; + } + + init(); +} diff --git a/src/front/static/ts/views/Aview.ts b/src/front/static/ts/views/Aview.ts new file mode 100644 index 0000000..810d3ab --- /dev/null +++ b/src/front/static/ts/views/Aview.ts @@ -0,0 +1,10 @@ +export default class { + contructor() + { + } + + setTitle(title) { document.title = title; } + + async getHTML() { return ""; } + async run() { } +}; diff --git a/src/front/static/ts/views/Friends.ts b/src/front/static/ts/views/Friends.ts new file mode 100644 index 0000000..2d6d72a --- /dev/null +++ b/src/front/static/ts/views/Friends.ts @@ -0,0 +1,200 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.ts"; +import { setOnekoState } from "../oneko.ts" +import { isLogged, navigationManager, user_api, auth_api } from "../main.ts" + +export default class extends Aview { + + open: Boolean = false; + + constructor() { + super(); + this.setTitle("friends list"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ friends.ts +
+ +
+
+ + + + +
+ + +
+ +
+
+
+ `; + } + + async run() { + if (!await isLogged()) + navigationManager("/"); + + if (this.open === true) { + document.getElementById("friends-menu").innerHTML = await this.getHTML(); + } else { + document.getElementById("friends-menu").innerHTML = ""; + return; + } + + let uuid: String; + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + const friends_error_message = (document.getElementById("friends-error-message") as HTMLParagraphElement); + const friends_list = (document.getElementById("friends_list") as HTMLUListElement); + const new_friend = (document.getElementById("new-friend") as HTMLInputElement); + const add_friend_err = (document.getElementById("add-friend-err") as HTMLParagraphElement); + const add_friend_msg = (document.getElementById("add-friend-msg") as HTMLParagraphElement); + + async function removeFriend(name: String) { + const data_req = await fetch(user_api + "/users/" + uuid + "/friends/" + name, { + method: "DELETE", + credentials: "include", + }); + if (data_req.status === 200) { + console.log("friends removed successfully"); + } else { + console.log("could not remove friend"); + } + list_friends(); + list_friends(); + list_friends(); + list_friends(); + list_friends(); + list_friends(); + } + + async function isFriendLogged(name: string): Promise { + const data_req = await fetch(user_api + "/ping/" + name, { + method: "GET", + credentials: "include", + }); + + if (data_req.status === 404) + return false; + + return (await data_req.json()).isLogged + } + + const list_friends = async () => { + const data_req = await fetch(user_api + "/users/" + uuid + "/friends/count", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + if (data_req.status === 404) { + } + let data = await data_req.json(); + while (friends_list.firstChild) { + friends_list.removeChild(friends_list.firstChild); + } + + if (data.n_friends > 0) { + const list_req = await fetch(user_api + "/users/" + uuid + "/friends?iStart=0&iEnd=2147483647", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + if (list_req.status == 404) { + friends_list.classList.add("hidden") + return; + } else if (list_req.status === 200) { + friends_list.classList.remove("hidden") + + let list = (await list_req.json()).friends as JSON; + + for (let i = 0; i < data.n_friends; i++) { + let new_friends = document.createElement('li'); + + const activitySpan = document.createElement('span'); + const isLogged = await isFriendLogged(list[i].friendName) + activitySpan.textContent = "•"; + if (isLogged == true) + activitySpan.className = "px-0 text-green-500"; + else + activitySpan.className = "px-0 text-red-500"; + + + const span = document.createElement('span'); + span.className = "px-3"; + span.textContent = list[i].friendName; + + const but = document.createElement('button'); + but.textContent = "-"; + but.classList.add("px-0", "py-0", "taskbar-button"); + but.onclick = function() { + removeFriend(list[i].friendName); + }; + + new_friends.appendChild(activitySpan); + new_friends.appendChild(span); + new_friends.appendChild(but); + friends_list.appendChild(new_friends); + } + } else { + friends_error_message.innerHTML = (await list_req.json()).error; + friends_error_message.classList.remove("hidden"); + } + } else { + friends_list.classList.add("hidden") + } + } + + const add_friend = async () => { + const data_req = await fetch(user_api + "/users/" + uuid + "/friends/" + new_friend.value, { + method: "POST", + credentials: "include", + }); + let data = await data_req.json() + if (data_req.status === 200) { + add_friend_msg.innerHTML = data.msg; + add_friend_msg.classList.remove("hidden"); + if (!add_friend_err.classList.contains("hidden")) + add_friend_err.classList.add("hidden") + } else { + add_friend_err.innerHTML = data.error; + add_friend_err.classList.remove("hidden"); + if (!add_friend_msg.classList.contains("hidden")) + add_friend_msg.classList.add("hidden") + list_friends() + return + } + list_friends() + new_friend.value = ''; + } + + try { + const data_req = await fetch(user_api + "/users/" + uuid + "/friends/count", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + if (data_req.status === 200) { + // let data = await data_req.json(); + list_friends() + } + } catch (error) { + friends_error_message.innerHTML = "failed to fetch friends"; + friends_error_message.classList.remove("hidden"); + } + document.getElementById("add-friends-button")?.addEventListener("click", add_friend); + setInterval(list_friends, 30000); + } +} diff --git a/src/front/static/ts/views/LoginPage.ts b/src/front/static/ts/views/LoginPage.ts new file mode 100644 index 0000000..eaf49ca --- /dev/null +++ b/src/front/static/ts/views/LoginPage.ts @@ -0,0 +1,197 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.ts" +import { setOnekoState } from "../oneko.ts" +import { isLogged, navigationManager, user_api, auth_api } from "../main.ts" + +export default class extends Aview { + + constructor() + { + super(); + this.setTitle("login"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ login.ts +
+ + + × +
+
+ +
+
+

welcome back ! please login.

+
+ + +
+ +
+ + + +
+ + +
+ +
+ `; + } + + async run() { + document.getElementById("login-google").href = `${auth_api}/login/google`; + dragElement(document.getElementById("window")); + + const totpVerify = async () => { + const username = (document.getElementById("username") as HTMLInputElement).value; + const password = (document.getElementById("password") as HTMLInputElement).value; + const totpPin = (document.getElementById('totpPin') as HTMLInputElement).value; + const idWindow = (document.getElementById('2fa-popup-content') as HTMLInputElement); + try { + const data_req = await fetch(auth_api + "/login", { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ user: username, password: password, token: totpPin }), + }); + if (data_req.status === 200) { + isLogged(); + navigationManager("/"); + } else if (data_req.status === 401) { + const data = await data_req.json(); + + if (!document.getElementById("error-totp")) { + const error = document.createElement("p"); + error.innerHTML = data.error; + error.classList.add("text-red-700", "dark:text-red-500"); + + idWindow.appendChild(error); + } else { + const error = document.getElementById("error-totp") as HTMLParagraphElement; + error.innerHTML = data.error; + } + + } else { + console.log(data_req.status) + console.log(await data_req.json()) + // throw new Error("invalid response"); + } + } catch (error) { + console.error(error); + } + } + + const login = async () => { + const username = (document.getElementById("username") as HTMLInputElement).value; + const password = (document.getElementById("password") as HTMLInputElement).value; + + try { + const data_req = await fetch(auth_api + "/login", { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ user: username, password: password }), + }); + + if (data_req.status === 200) + { + isLogged(); + navigationManager("/"); + } + else if (data_req.status === 402) { + const popup: HTMLDivElement = document.createElement("div"); + popup.id = "2fa-popup"; + popup.classList.add("z-10", "absolute", "default-border"); + const header = popup.appendChild(document.createElement("div"));; + header.classList.add("bg-linear-to-r", "from-orange-200", "to-orange-300", "flex", "flex-row", "min-w-35", "justify-between", "px-2"); + header.id = "2fa-header"; + header.appendChild(document.createElement("span")).innerText = "2fa.ts"; + const btn = header.appendChild(document.createElement("button")); + btn.innerText = " × "; + btn.onclick = () => { document.getElementById("2fa-popup").remove(); }; + + const popup_content: HTMLSpanElement = popup.appendChild(document.createElement("div")); + popup_content.id = "2fa-popup-content"; + popup_content.classList.add("flex", "flex-col", "bg-neutral-200", "dark:bg-neutral-800", "p-6", "pt-4", "text-neutral-900", "dark:text-white", "space-y-4"); + + const tokenInput = document.createElement("input"); + tokenInput.type = "tel"; + tokenInput.id = "totpPin"; + tokenInput.name = "totpPin"; + tokenInput.placeholder = "TOTP code"; + tokenInput.required = true; + tokenInput.autocomplete = "off"; + tokenInput.pattern = "[0-9]*"; + tokenInput.setAttribute("inputmode", "numeric"); + tokenInput.classList.add("bg-white", "text-neutral-900","w-full", "px-4", "py-2", "input-border"); + + const tokenSubmit = document.createElement("button"); + tokenSubmit.type = "submit"; + tokenSubmit.classList.add("default-button", "w-full"); + tokenSubmit.id = "totp-submit"; + tokenSubmit.innerHTML = "submit"; + + const tokenTitle = document.createElement("h1"); + tokenTitle.innerHTML = `hey ${username}, please submit your 2fa code below :`; + tokenTitle.classList.add("text-gray-900", "dark:text-white", "text-lg", "pt-0", "pb-4", "justify-center"); + + const form = document.createElement("form"); + form.method = "dialog"; + form.classList.add("space-y-4"); + form.appendChild(tokenTitle); + form.appendChild(tokenInput); + form.appendChild(tokenSubmit); + + popup_content.appendChild(form); + + const uu = document.getElementById("username") as HTMLInputElement; + const pass = document.getElementById("password") as HTMLInputElement; + + uu.disabled = true; + pass.disabled = true; + + document.getElementById("app")?.appendChild(popup); + tokenInput.focus(); + dragElement(document.getElementById("2fa-popup")); + + document.getElementById("totp-submit")?.addEventListener("click", totpVerify); + } + else if (data_req.status === 400) + { + const data = await data_req.json(); + document.getElementById("login-error-message").innerHTML = "error: " + data.error; + document.getElementById("login-error-message").classList.remove("hidden"); + } + else + { + throw new Error("invalid response"); + } + + } + catch (error) + { + console.error(error); + document.getElementById("login-error-message").innerHTML = "error: server error, try again later..."; + document.getElementById("login-error-message").classList.remove("hidden"); + } + }; + + document.getElementById("login-button")?.addEventListener("click", login); + } +} diff --git a/src/front/static/ts/views/MainMenu.ts b/src/front/static/ts/views/MainMenu.ts new file mode 100644 index 0000000..8639808 --- /dev/null +++ b/src/front/static/ts/views/MainMenu.ts @@ -0,0 +1,35 @@ +import Aview from "./Aview.ts" +import { isLogged} from "../main.ts" +import { setOnekoState } from "../oneko.ts" + +export default class extends Aview { + + constructor() + { + super(); + this.setTitle("knl is trans(cendence)"); + setOnekoState("default"); + } + + async getHTML() { + //
+ return ` + + `; + } +} diff --git a/src/front/static/ts/views/Pong.ts b/src/front/static/ts/views/Pong.ts new file mode 100644 index 0000000..72aacf0 --- /dev/null +++ b/src/front/static/ts/views/Pong.ts @@ -0,0 +1,343 @@ +import Aview from "./Aview.ts" +import { isLogged, user_api, auth_api } from "../main.js" +import { dragElement } from "./drag.js" +import { setOnekoState, setBallPos, setOnekoOffset } from "../oneko.ts" + +export default class extends Aview { + + running: boolean; + + constructor() + { + super(); + this.setTitle("pong (local match)"); + this.running = true; + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ pong_game.ts +
+ + + × +
+
+ + +
+
+

enter the users ids/names

+
+ + +
+ +
+ +
+ `; + } + + async run() { + dragElement(document.getElementById("window")); + let uuid: string; + + let start: number = 0; + let elapsed: number; + + let game_playing: boolean = false; + let match_over: boolean = false; + let p1_score: number = 0; + let p2_score: number = 0; + let p1_name: string; + let p2_name: string; + + let p1_displayName: string; + let p2_displayName: string; + + let countdown: number = 3; + let countdownTimer: number = 0; + + let canvas; + let ctx; + + const paddleOffset: number = 15; + const paddleHeight: number = 100; + const paddleWidth: number = 10; + const ballSize: number = 10; + + const paddleSpeed: number = 727 * 0.69; + let leftPaddleY: number; + let rightPaddleY: number; + let ballX: number; + let ballY: number; + let ballSpeed: number = 200; + let ballSpeedX: number = 300; + let ballSpeedY: number = 10; + + const keys: Record = {}; + + document.addEventListener("keydown", e => { keys[e.key] = true; }); + document.addEventListener("keyup", e => { keys[e.key] = false; }); + + function movePaddles() { + if ((keys["w"] || keys["W"]) && leftPaddleY > 0) + leftPaddleY -= paddleSpeed * elapsed; + if ((keys["s"] || keys["S"]) && leftPaddleY < canvas.height - paddleHeight) + leftPaddleY += paddleSpeed * elapsed; + if (keys["ArrowUp"] && rightPaddleY > 0) + rightPaddleY -= paddleSpeed * elapsed; + if (keys["ArrowDown"] && rightPaddleY < canvas.height - paddleHeight) + rightPaddleY += paddleSpeed * elapsed; + } + + function getBounceVelocity(paddleY) { + const speed = ballSpeed; + const paddleCenterY = paddleY + paddleHeight / 2; + + let n = (ballY - paddleCenterY) / (paddleHeight / 2); + n = Math.max(-1, Math.min(1, n)); + let theta = n * ((75 * Math.PI) / 180); + ballSpeedY = ballSpeed * Math.sin(theta); + } + + async function moveBall() { + let length = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); + let scale = ballSpeed / length; + ballX += (ballSpeedX * scale) * elapsed; + ballY += (ballSpeedY * scale) * elapsed; + + if (ballY <= 0 || ballY >= canvas.height - ballSize) + ballSpeedY *= -1; + + if (ballX <= paddleWidth + paddleOffset && ballX >= paddleOffset && + ballY > leftPaddleY && ballY < leftPaddleY + paddleHeight) + { + ballSpeedX *= -1; + ballX = paddleWidth + paddleOffset; + getBounceVelocity(leftPaddleY); + ballSpeed += 10; + } + + if (ballX >= canvas.width - paddleWidth - ballSize - paddleOffset && ballX <= canvas.width - ballSize - paddleOffset && + ballY > rightPaddleY && ballY < rightPaddleY + paddleHeight) + { + ballSpeedX *= -1; + ballX = canvas.width - paddleWidth - ballSize - paddleOffset; + getBounceVelocity(rightPaddleY); + ballSpeed += 10; + } + + // scoring + if (ballX < 0 || ballX > canvas.width - ballSize) + { + setOnekoState("default"); + game_playing = false; + if (ballX < 0) + p2_score++; + else + p1_score++; + + if (p1_score === 3 || p2_score === 3) + { + console.log(isLogged()); + if (await isLogged()) + { + let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + fetch(user_api + "/users/" + uuid + "/matchHistory?game=pong", { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ + "game": "pong", + "opponent": p2_name, + "myScore": p1_score, + "opponentScore": p2_score, + "date": Date.now(), + }), + }); + } + match_over = true; + } + else + { + countdown = 3; + countdownTimer = performance.now(); + } + + ballX = canvas.width / 2; + ballY = canvas.height / 2; + ballSpeed = 200; + ballSpeedX = 300 * ((ballSpeedX > 0) ? 1 : -1); + ballSpeedY = 10; + ballSpeedX = -ballSpeedX; + leftPaddleY = canvas.height / 2 - paddleHeight / 2; + rightPaddleY = canvas.height / 2 - paddleHeight / 2; + } + setBallPos(ballX, ballY); + } + + function draw() { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = "white"; + ctx.beginPath(); + ctx.setLineDash([5, 10]); + ctx.moveTo(canvas.width / 2, 0); + ctx.lineTo(canvas.width / 2, canvas.height); + ctx.stroke(); + + ctx.fillStyle = "white"; + ctx.fillRect(paddleOffset, leftPaddleY, paddleWidth, paddleHeight); + ctx.fillRect(canvas.width - paddleWidth - paddleOffset, rightPaddleY, paddleWidth, paddleHeight); + + ctx.fillStyle = "white"; + if (game_playing) + ctx.fillRect(ballX, ballY, ballSize, ballSize); + + ctx.font = "24px Kubasta"; + let text_score = `${p1_score} - ${p2_score}`; + ctx.fillText(text_score, canvas.width / 2 - (ctx.measureText(text_score).width / 2), 25); + ctx.fillText(p1_displayName, canvas.width / 4 - (ctx.measureText(p1_name).width / 2), 45); + ctx.fillText(p2_displayName, (canvas.width / 4 * 3) - (ctx.measureText(p2_name).width / 2), 45); + + if (match_over) + { + ctx.font = "32px Kubasta"; + const winner = `${p1_score > p2_score ? p1_name : p2_name} won :D`; + ctx.fillText(winner, canvas.width / 2 - (ctx.measureText(winner).width / 2), canvas.height / 2 + 16); + document.getElementById("game-buttons").classList.remove("hidden"); + } + } + + function startCountdown() + { + const now = performance.now(); + if (countdown > 0) + { + if (now - countdownTimer >= 500) + { + countdown--; + countdownTimer = now; + } + ctx.font = "48px Kubasta"; + ctx.fillText(countdown.toString(), canvas.width / 2 - 10, canvas.height / 2 + 24); + } + else if (countdown === 0) + { + ctx.font = "48px Kubasta"; + ctx.fillText("Go!", canvas.width / 2 - 30, canvas.height / 2 + 24); + setTimeout(() => { + game_playing = true; + countdown = -1; + }, 500); + } + } + + const gameLoop = async (timestamp: number) => { + elapsed = (timestamp - start) / 1000; + start = timestamp; + if (game_playing) + { + movePaddles(); + await moveBall(); + } + draw(); + if (!game_playing) + startCountdown(); + if (this.running) + requestAnimationFrame(gameLoop); + }; + + + document.getElementById("game-retry")?.addEventListener("click", () => { + setOnekoState("pong"); + document.getElementById("game-buttons").classList.add("hidden"); + game_playing = false; + match_over = false; + p1_score = 0; + p2_score = 0; + + countdown = 3; + countdownTimer = performance.now(); + }); + let p1_input: HTMLInputElement = document.getElementById("player1") as HTMLInputElement; + let p2_input: HTMLInputElement = document.getElementById("player2") as HTMLInputElement; + + p2_input.value = "player 2"; + if (await isLogged()) + { + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + + p1_input.value = uuid; + p1_input.readOnly = true; + } + else + p1_input.value = "player 1"; + + document.getElementById("game-start")?.addEventListener("click", async () => { + let p1_isvalid = true; + let p2_isvalid = true; + if (await isLogged()) { + const p1_req = await fetch(`${user_api}/users/${p1_input.value}`, { + method: "GET", + credentials: "include", + }); + const p2_req = await fetch(`${user_api}/users/${p2_input.value}`, { + method: "GET", + credentials: "include", + }); + if (p1_req.status != 200) + p1_isvalid = false; + else + p1_displayName = (await p1_req.json()).displayName; + + if (p2_req.status != 200) + p2_isvalid = false; + else + p2_displayName = (await p2_req.json()).displayName; + } + else + p1_isvalid = p2_isvalid = false; + p1_name = p1_input.value; + p2_name = p2_input.value; + if (!p1_isvalid) + p1_displayName = p1_name; + if (!p2_isvalid) + p2_displayName = p2_name; + + p1_displayName = p1_displayName.length > 16 ? p1_displayName.substring(0, 16) + "." : p1_displayName; + p2_displayName = p2_displayName.length > 16 ? p2_displayName.substring(0, 16) + "." : p2_displayName; + p1_name = p1_input.value.length > 16 ? p1_input.value.substring(0, 16) + "." : p1_input.value; + p2_name = p2_input.value.length > 16 ? p2_input.value.substring(0, 16) + "." : p2_input.value; + document.getElementById("player-inputs").remove(); + + canvas = document.createElement("canvas"); + canvas.id = "gameCanvas"; + canvas.classList.add("reverse-border"); + + document.getElementById("main-div").prepend(canvas); + + ctx = canvas.getContext("2d", {alpha: false}); + ctx.canvas.width = 600; + ctx.canvas.height = 600; + + leftPaddleY = canvas.height / 2 - paddleHeight / 2; + rightPaddleY = canvas.height / 2 - paddleHeight / 2; + ballX = canvas.width / 2; + ballY = canvas.height / 2; + + setOnekoState("pong"); + setOnekoOffset(); + requestAnimationFrame(gameLoop); + }); + } +} diff --git a/src/front/static/ts/views/PongMenu.ts b/src/front/static/ts/views/PongMenu.ts new file mode 100644 index 0000000..e7b3bc0 --- /dev/null +++ b/src/front/static/ts/views/PongMenu.ts @@ -0,0 +1,42 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.js" +import { setOnekoState } from "../oneko.ts" + +export default class extends Aview { + + constructor() + { + super(); + this.setTitle("knl is trans(cendence)"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ pong_game.ts +
+ + + × +
+
+
+

welcome to pong!! Oo

+ +
+
+ `; + } + async run() { + dragElement(document.getElementById("window")); + } +} diff --git a/src/front/static/ts/views/Profile.ts b/src/front/static/ts/views/Profile.ts new file mode 100644 index 0000000..25258ab --- /dev/null +++ b/src/front/static/ts/views/Profile.ts @@ -0,0 +1,226 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.ts"; +import { setOnekoState } from "../oneko.ts" +import { isLogged, navigationManager, user_api, auth_api } from "../main.ts" + + +export default class extends Aview { + + constructor() + { + super(); + this.setTitle("profile"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ profile.ts +
+ + + × +
+
+
+
+
+
+
+
    +
+
    +
+
+
+
+
+ `; + } + + async run() { + if (!await isLogged()) + navigationManager("/"); + + let pc: number = 0; + dragElement(document.getElementById("window")); + let uuid: String; + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + + const userdata_req = await fetch(`${user_api}/users/${uuid}`, { + method: "GET", + credentials: "include", + }); + if (userdata_req.status == 404) { + console.error("invalid user"); + return; + } + let userdata = await userdata_req.json(); + + let matchCount_req = await fetch(`${user_api}/users/${uuid}/matchHistory/count?game=pong`, { + method: "GET", + credentials: "include", + }); + let matchCount = await matchCount_req.json(); + pc += matchCount.n_matches; + + let matches_req = await fetch(`${user_api}/users/${uuid}/matchHistory?game=pong&iStart=0&iEnd=${matchCount.n_matches}`, { + method: "GET", + credentials: "include", + }); + let matches = await matches_req.json(); + + let main = document.getElementById("profile-pong-scorelist"); + if (!main) + return console.error("what"); + + if (matches.matchHistory) { + for (let match of matches.matchHistory) { + const p2_req = await fetch(`${user_api}/users/${match.score.p2}`, { + method: "GET", + credentials: "include", + }); + match.score.p1 = userdata.displayName; + match.score.p2 = (await p2_req.json()).displayName; + const newEntry = document.createElement("li"); + newEntry.classList.add("m-1", "default-button", "bg-neutral-200", "dark:bg-neutral-800", "text-neutral-900", "dark:text-white"); + newEntry.innerHTML = match.score.p1Score > match.score.p2Score ? `${match.score.p1} - winner` : `${match.score.p2} - winner`; + main.insertBefore(newEntry, main.firstChild); + + const popup: HTMLDivElement = document.createElement("div"); + const id: number = Math.floor(Math.random() * 100000000000); + popup.id = `${id}`; + popup.classList.add("z-10", "absolute", "default-border"); + const header = popup.appendChild(document.createElement("div")); + header.classList.add("bg-linear-to-r", "from-orange-200", "to-orange-300", "flex", "flex-row", "min-w-35", "justify-between", "px-2"); + header.id = `${id}-header`; + const title = header.appendChild(document.createElement("span")); + title.classList.add("font-[Kubasta]"); + title.innerText = "score-pong.ts"; + const btn = header.appendChild(document.createElement("button")); + btn.innerText = " × "; + btn.onclick = () => { document.getElementById(`${id}`).remove(); }; + + const popup_content: HTMLSpanElement = popup.appendChild(document.createElement("div")); + popup_content.classList.add("flex", "flex-col", "bg-neutral-200", "dark:bg-neutral-800", "p-6", "pt-4", "text-neutral-900", "dark:text-white", "space-y-4"); + const date = new Date(match.score.date); + popup_content.appendChild(document.createElement("span")).innerText = `${date.toDateString()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; + const score = popup_content.appendChild(document.createElement("span")); + score.classList.add(); + score.innerText = `${match.score.p1} : ${match.score.p1Score} - ${match.score.p2Score} : ${match.score.p2}`; + const tx = popup_content.appendChild(document.createElement("a")); + tx.href = `https://testnet.snowscan.xyz/tx/${match.tx}`; + tx.innerText = "transaction proof"; + tx.target = "_blank"; + tx.classList.add("default-button", "items-center", "justify-center", "text-center"); + + newEntry.onclick = () => { document.getElementById("app")?.appendChild(popup); dragElement(document.getElementById(`${id}`)); }; + console.log(match.tx); + } + } + + matchCount_req = await fetch(`${user_api}/users/${uuid}/matchHistory/count?game=tetris`, { + method: "GET", + credentials: "include", + }); + matchCount = await matchCount_req.json(); + pc += matchCount.n_matches; + + matches_req = await fetch(`${user_api}/users/${uuid}/matchHistory?game=tetris&iStart=0&iEnd=${matchCount.n_matches}`, { + method: "GET", + credentials: "include", + }); + matches = await matches_req.json(); + + main = document.getElementById("profile-tetris-scorelist"); + if (!main) + return console.error("what"); + + // don't read this shit for you mental health + if (matches.matchHistory) { + for (let match of matches.matchHistory) { + if (match.score.p2 != undefined) + { + const p2_req = await fetch(`${user_api}/users/${match.score.p2}`, { + method: "GET", + credentials: "include", + }); + match.score.p2 = (await p2_req.json()).displayName; + } + match.score.p1 = userdata.displayName; + const newEntry = document.createElement("li"); + newEntry.classList.add("m-1", "default-button", "bg-neutral-200", "dark:bg-neutral-800", "text-neutral-900", "dark:text-white"); + newEntry.innerHTML = match.score.p2 != undefined ? + (match.score.p1Score > match.score.p2Score ? `${match.score.p1} - winner` : `${match.score.p2} - winner`) + : + (`solo game - ${match.score.p1Score}`) + ; + main.insertBefore(newEntry, main.firstChild); + + const popup: HTMLDivElement = document.createElement("div"); + const id: number = Math.floor(Math.random() * 100000000000); + popup.id = `${id}`; + popup.classList.add("z-10", "absolute", "default-border"); + const header = popup.appendChild(document.createElement("div")); + header.classList.add("bg-linear-to-r", "from-orange-200", "to-orange-300", "flex", "flex-row", "min-w-35", "justify-between", "px-2"); + header.id = `${id}-header`; + const title = header.appendChild(document.createElement("span")); + title.classList.add("font-[Kubasta]"); + title.innerText = "score-tetris.ts"; + const btn = header.appendChild(document.createElement("button")); + btn.innerText = " × "; + btn.onclick = () => { document.getElementById(`${id}`).remove(); }; + + const popup_content: HTMLSpanElement = popup.appendChild(document.createElement("div")); + popup_content.classList.add("flex", "flex-col", "bg-neutral-200", "dark:bg-neutral-800", "p-6", "pt-4", "text-neutral-900", "dark:text-white", "space-y-4"); + const date = new Date(match.score.date); + popup_content.appendChild(document.createElement("span")).innerText = `${date.toDateString()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; + const score = popup_content.appendChild(document.createElement("span")); + score.classList.add(); + score.innerText = match.score.p2 != undefined ? + (`${match.score.p1} : ${match.score.p1Score} - ${match.score.p2Score} : ${match.score.p2}`) + : + (`${match.score.p1} : ${match.score.p1Score}`) + ; + const tx = popup_content.appendChild(document.createElement("a")); + tx.href = `https://testnet.snowscan.xyz/tx/${match.tx}`; + tx.innerText = "transaction proof"; + tx.target = "_blank"; + tx.classList.add("default-button", "items-center", "justify-center", "text-center"); + + newEntry.onclick = () => { document.getElementById("app")?.appendChild(popup); dragElement(document.getElementById(`${id}`)); }; + console.log(match.tx); + } + } + + const profile = document.getElementById("profile-profile"); + if (!profile) return; + + const picture = profile.appendChild(document.createElement("img")); + const a = await fetch(`${user_api}/users/${uuid}/avatar`, { + method: "GET", + credentials: "include", + }); + picture.src = a.status === 200 + ? `${user_api}/users/${uuid}/avatar?t=${Date.now()}` + : "https://api.kanel.ovh/pp"; + picture.classList.add("text-neutral-900", "dark:text-white", "center", "h-18", "w-18", "mx-3", "reverse-border"); + + const nametag = profile.appendChild(document.createElement("div")); + nametag.innerHTML = ` +
Hi ${userdata.displayName} ! :D
+
${uuid}
+ `; + nametag.classList.add("text-neutral-900", "dark:text-white"); + + const winrate = profile.appendChild(document.createElement("div")); + winrate.innerHTML = ` +
total playcount: ${pc}
+
pong winrate: ${ (userdata.pong.wins == 0 && userdata.pong.losses == 0) ? "-" : Math.round(userdata.pong.wins / (userdata.pong.wins + userdata.pong.losses) * 100) + " %" }
+
tetris winrate: ${ (userdata.tetris.wins == 0 && userdata.tetris.losses == 0) ? "-" : Math.round(userdata.tetris.wins / (userdata.tetris.wins + userdata.tetris.losses) * 100) + " %" }
+ `; + winrate.classList.add("text-neutral-900", "dark:text-white", "grow", "content-center"); + } +} diff --git a/src/front/static/ts/views/ProfileMenu.ts b/src/front/static/ts/views/ProfileMenu.ts new file mode 100644 index 0000000..9a480e0 --- /dev/null +++ b/src/front/static/ts/views/ProfileMenu.ts @@ -0,0 +1,87 @@ +import Aview from "./Aview.ts" +import { isLogged, user_api, auth_api } from "../main.ts" + +export default class extends Aview { + async getHTML() { + return ` +
+
+
+
knl_meowscendence
+
+ +
+
+
+ +
+
+
+ `; + } + + open: boolean = false; + + async run() { + let uuid: String; + if (this.open) + { + this.open = false; + document.getElementById("taskbar-menu").innerHTML = ""; + return ; + } + this.open = true; + document.getElementById("taskbar-menu").innerHTML = await this.getHTML(); + + async function getMainHTML() { + if (!(await isLogged())) + { + document.getElementById("menu-bottom-div")?.classList.add("hidden"); + return ` + login + register + `; + } + document.getElementById("menu-bottom-div")?.classList.remove("hidden"); + + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + const userdata_req = await fetch(`${user_api}/users/${uuid}`, { + method: "GET", + credentials: "include", + }); + if (userdata_req.status == 404) + { + console.error("invalid user"); + return ; + } + let userdata = await userdata_req.json(); + + return ` + hi, ${ userdata.displayName.length > 8 ? userdata.displayName.substring(0, 8) + "." : userdata.displayName } ! +
+ profile + settings + `; + } + + document.getElementById("profile-items").innerHTML = await getMainHTML(); + requestAnimationFrame(() => { + document.getElementById("menu-logout").addEventListener("click", async () => { + let req = fetch(`${auth_api}/logout`, { + method: "GET", + credentials: "include", + }); + req.then((res) => { + isLogged(); + if (res.status === 200) + this.run(); + else + console.error("logout failed"); + }); + }); + }); + } +} diff --git a/src/front/static/ts/views/RegisterPage.ts b/src/front/static/ts/views/RegisterPage.ts new file mode 100644 index 0000000..ce19458 --- /dev/null +++ b/src/front/static/ts/views/RegisterPage.ts @@ -0,0 +1,104 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.ts"; +import { setOnekoState } from "../oneko.ts" +import { isLogged, navigationManager, user_api, auth_api } from "../main.ts" + +export default class extends Aview { + + constructor() { + super(); + this.setTitle("register"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ register.ts +
+ + + × +
+
+ +
+
+

welcome ! please register.

+
+ + +
+ +
+ + + +
+ + +
+ +
+ `; + } + + async run() { + document.getElementById("register-google").href = `${auth_api}/register/google`; + dragElement(document.getElementById("window")); + const login = async () => { + const username = (document.getElementById("username") as HTMLInputElement).value; + const password = (document.getElementById("password") as HTMLInputElement).value; + + try { + const data_req = await fetch(auth_api + "/register", { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ user: username, password: password }), + }); + const data = await data_req.json(); + + if (data_req.status === 200) { + let uuid_req = await fetch(auth_api + "/me", { + method: "GET", + credentials: "include", + }); + let uuid = await uuid_req.json(); + document.cookie = `uuid=${uuid.user};max-ages=${60 * 60 * 24 * 7}`; + isLogged(); + navigationManager("/"); + } + else if (data_req.status === 400) { + if (document.getElementById("login-error-message")) { + document.getElementById("login-error-message").innerHTML = "error: " + data.error; + document.getElementById("login-error-message")?.classList.remove("hidden"); + } + } + else { + throw new Error("invalid response"); + } + + } + catch (error) { + console.error(error); + if (document.getElementById("login-error-message")) { + document.getElementById("login-error-message").innerHTML = "error: server error, try again later..."; + document.getElementById("login-error-message")?.classList.remove("hidden"); + } + } + }; + + document.getElementById("register-button")?.addEventListener("click", login); + } +} diff --git a/src/front/static/ts/views/Settings.ts b/src/front/static/ts/views/Settings.ts new file mode 100644 index 0000000..f2ab968 --- /dev/null +++ b/src/front/static/ts/views/Settings.ts @@ -0,0 +1,181 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.ts"; +import { setOnekoState } from "../oneko.ts" +import { totpEnablePopup } from "./TotpEnable.ts"; +import { totpVerify } from "../../../../api/auth/totpVerify.js"; +import { isLogged, navigationManager, user_api, auth_api } from "../main.ts" + +export default class extends Aview { + + constructor() { + super(); + this.setTitle("profile"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ settings.ts +
+ + + × +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ `; + } + + async run() { + if (!await isLogged()) + navigationManager("/"); + + dragElement(document.getElementById("window")); + + const isTOTPEnabled = async () => { + const totpVerify_req = await fetch(auth_api + '/2fa', { + method: "GET", + credentials: "include" + }) + + if (totpVerify_req.status === 200) { + const totpVerify_data = await totpVerify_req.json(); + if (totpVerify_data.totp == true) { + return true; + } + } + return false; + }; + + let uuid: String; + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + const userdata_req = await fetch(`${user_api}/users/${uuid}`, { + method: "GET", + credentials: "include", + }); + if (userdata_req.status == 404) { + console.error("invalid user"); + return; + } + let userdata = await userdata_req.json(); + + (document.getElementById("displayName-input") as HTMLInputElement).placeholder = userdata.displayName; + (document.getElementById("displayName-input") as HTMLInputElement).value = userdata.displayName; + + document.getElementById("displayName-button")?.addEventListener("click", async () => { + const changeDisplayName_req = await fetch(`${user_api}/users/${uuid}/displayName`, { + method: "PATCH", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ displayName: (document.getElementById("displayName-input") as HTMLInputElement).value }) + }); + if (changeDisplayName_req.status == 200) { + // idk display success + } + else { + // display error ig, uuuh it's in await changeDisplayName.json().error + } + }); + + document.getElementById("deleteAccount-button")?.addEventListener("click", async () => { + const delete_req = await fetch(auth_api + "/", { + method: "DELETE", + credentials: "include", + }); + + if (delete_req.status == 200) + navigationManager("/"); + else + console.error("xd"); // xd????????????? + }); + + const upload = document.getElementById("upload-file") as HTMLInputElement; + upload.addEventListener("change", () => { + const fileList: FileList | null = upload.files; + if (!fileList) + return console.error("empty"); + if (!fileList[0].type.startsWith("image/")) { + console.error("invalid file"); + return; + } + + document.getElementById("upload-preview")?.classList.remove("hidden"); + const img = document.getElementById("upload-preview-img") as HTMLImageElement; + img.classList.remove("hidden"); + + const reader = new FileReader(); + reader.onload = (e) => { + if (!e.target) + return; + img.src = e.target.result as string; + }; + + reader.readAsDataURL(fileList[0]); + }); + + (document.getElementById("upload-submit") as HTMLButtonElement).onclick = async () => { + const up_req = await fetch(`${user_api}/users/${uuid}/avatar`, { + method: "POST", + headers: { "Content-Type": upload.files[0].type } , + credentials: "include", + body: upload.files[0], //upload uuuh whatever i have to upload + }); + console.log(up_req.status); + }; + + const totpButton = document.getElementById("2fa-button") as HTMLButtonElement; + + if ((await isTOTPEnabled()) === true) { + totpButton.innerHTML = "disable 2fa"; + + document.getElementById("2fa-button")?.addEventListener("click", async () => { + const totp_req = await fetch(`${user_api}/2fa`, { + method: "DELETE", + credentials: "include" + }) + if (totp_req.status === 200) { + console.log("working") + navigationManager("/settings") + } else { + console.log("wut") + } + }); + } else { + totpButton.innerHTML = "enable 2fa"; + + document.getElementById("2fa-button")?.addEventListener("click", async () => { + const totp_req = await fetch(`${user_api}/2fa`, { + method: "POST", + credentials: "include" + }) + if (totp_req.status === 200) { + console.log("working") + const totp_data = await totp_req.json(); + totpEnablePopup(uuid, totp_data.secret, totp_data.otpauthUrl); + } else { + console.log("wut") + } + }); + } + } +} diff --git a/src/front/static/ts/views/Tetris.ts b/src/front/static/ts/views/Tetris.ts new file mode 100644 index 0000000..496bbce --- /dev/null +++ b/src/front/static/ts/views/Tetris.ts @@ -0,0 +1,885 @@ +import Aview from "./Aview.ts"; +import { dragElement } from "./drag.js"; +import { setOnekoState, setBallPos, setOnekoOffset, setSleepPos } from "../oneko.ts"; +import { isLogged , user_api, auth_api } from "../main.js"; + +export default class extends Aview { + running: boolean; + + constructor() { + super(); + this.setTitle("tetris (local match)"); + setOnekoState("tetris"); + this.running = true; + } + + async getHTML() { + return ` +
+
+ tetris_game.ts +
+ + + × +
+
+ + +
+
+ + + +
+ +
+
+ `; + } + + async run() { + setSleepPos(); + dragElement(document.getElementById("window")); + const COLS = 10; + const ROWS = 20; + const BLOCK = 30; // pixels per block + + const view = this; + + type Cell = number; // 0 empty, >0 occupied (color index) + + // Tetromino definitions: each piece is an array of rotations, each rotation is a 2D matrix + const TETROMINOES: { [key: string]: number[][][] } = { + I: [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + ], + + [ + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + ], + ], + J: [ + [ + [2, 0, 0], + [2, 2, 2], + [0, 0, 0], + ], + + [ + [0, 2, 2], + [0, 2, 0], + [0, 2, 0], + ], + + [ + [0, 0, 0], + [2, 2, 2], + [0, 0, 2], + ], + + [ + [0, 2, 0], + [0, 2, 0], + [2, 2, 0], + ], + ], + L: [ + [ + [0, 0, 3], + [3, 3, 3], + [0, 0, 0], + ], + + [ + [0, 3, 0], + [0, 3, 0], + [0, 3, 3], + ], + + [ + [0, 0, 0], + [3, 3, 3], + [3, 0, 0], + ], + + [ + [3, 3, 0], + [0, 3, 0], + [0, 3, 0], + ], + ], + O: [ + [ + [4, 4], + [4, 4], + ], + ], + S: [ + [ + [0, 5, 5], + [5, 5, 0], + [0, 0, 0], + ], + + [ + [0, 5, 0], + [0, 5, 5], + [0, 0, 5], + ], + + [ + [0, 0, 0], + [0, 5, 5], + [5, 5, 0], + ], + + [ + [5, 0, 0], + [5, 5, 0], + [0, 5, 0], + ], + ], + T: [ + [ + [0, 6, 0], + [6, 6, 6], + [0, 0, 0], + ], + + [ + [0, 6, 0], + [0, 6, 6], + [0, 6, 0], + ], + + [ + [0, 0, 0], + [6, 6, 6], + [0, 6, 0], + ], + + [ + [0, 6, 0], + [6, 6, 0], + [0, 6, 0], + ], + ], + Z: [ + [ + [7, 7, 0], + [0, 7, 7], + [0, 0, 0], + ], + + [ + [0, 0, 7], + [0, 7, 7], + [0, 7, 0], + ], + + [ + [0, 0, 0], + [7, 7, 0], + [0, 7, 7], + ], + + [ + [0, 7, 0], + [7, 7, 0], + [7, 0, 0], + ], + ], + }; + + const COLORS = [ + [ "#000000", "#000000" ] , // placeholder for 0 + [ "#00d2e1", "#0080a8" ], // I - cyan + [ "#0092e9", "#001fbf" ], // J - blue + [ "#e79700", "#c75700" ], // L - orange + [ "#d8c800", "#8f7700" ], // O - yellow + [ "#59e000", "#038b00" ], // S - green + [ "#de1fdf", "#870087" ], // T - purple + [ "#f06600", "#c10d07" ], // Z - red + ]; + + class Piece { + shape: number[][]; + rotations: number[][][]; + rotationIndex: number; + x: number; + y: number; + colorIndex: number; + + constructor(public type: string) { + this.rotations = TETROMINOES[type]; + this.rotationIndex = 0; + this.shape = this.rotations[this.rotationIndex]; + this.colorIndex = this.findColorIndex(); + + this.x = Math.floor((COLS - this.shape[0].length) / 2); + this.y = -2; //start on tiles 21 and 22 + } + + findColorIndex() { + for (const row of this.shape) + for (const v of row) + if (v) + return v; + return 1; + } + + rotateCW() { + this.rotationIndex = (this.rotationIndex + 1) % this.rotations.length; + this.shape = this.rotations[this.rotationIndex]; + } + + rotateCCW() { + this.rotationIndex = + (this.rotationIndex - 1 + this.rotations.length) % + this.rotations.length; + this.shape = this.rotations[this.rotationIndex]; + } + + getCells(): { x: number; y: number; val: number }[] { + const cells: { x: number; y: number; val: number }[] = []; + + for (let r = 0; r < this.shape.length; r++) { + for (let c = 0; c < this.shape[r].length; c++) { + const val = this.shape[r][c]; + if (val) cells.push({ x: this.x + c, y: this.y + r, val }); + } + } + return cells; + } + } + + class Game { + board: Cell[][]; + canvas: HTMLCanvasElement | null; + holdCanvas: HTMLCanvasElement | null; + queueCanvas: HTMLCanvasElement | null; + ctx: CanvasRenderingContext2D | null; + holdCtx: CanvasRenderingContext2D | null; + queueCtx: CanvasRenderingContext2D | null; + piece: Piece | null = null; + holdPiece: Piece | null = null; + canHold: boolean = true; + nextQueue: string[] = []; + score: number = 0; + level: number = 1; + lines: number = 0; + dropInterval: number = 1000; + lastDrop: number = 0; + isLocking: boolean = false; + lockRotationCount: number = 0; + lockLastRotationCount: number = 0; + isGameOver: boolean = false; + isPaused: boolean = false; + + constructor(canvasId: string) { + const el = document.getElementById( + canvasId, + ) as HTMLCanvasElement | null; + this.canvas = el; + if (!this.canvas) + throw console.error("no canvas :c"); + this.canvas.width = COLS * BLOCK; + this.canvas.height = ROWS * BLOCK; + const ctx = this.canvas.getContext("2d"); + this.ctx = ctx; + if (!this.ctx) + throw console.error("no ctx D:"); + + this.holdCanvas = document.getElementById("hold") as HTMLCanvasElement; + this.queueCanvas = document.getElementById("queue") as HTMLCanvasElement; + if (!this.holdCanvas || !this.queueCanvas) + throw console.error("no canvas :c"); + this.holdCtx = this.holdCanvas.getContext("2d"); + this.queueCtx = this.queueCanvas.getContext("2d"); + if (!this.holdCtx || !this.queueCtx) + return; + + this.holdCtx.clearRect(0, 0, 200, 200); + this.queueCtx.clearRect(0, 0, 500, 500); + + this.board = this.createEmptyBoard(); + this.fillBag(); + + this.spawnPiece(); + this.registerListeners(); + requestAnimationFrame(this.loop.bind(this)); + } + + createEmptyBoard(): Cell[][] { + const b: Cell[][] = []; + for (let r = 0; r < ROWS; r++) { + const row: Cell[] = new Array(COLS).fill(0); + b.push(row); + } + return b; + } + + fillBag() { + // classic 7-bag randomizer + const pieces = Object.keys(TETROMINOES); + const bag = [...pieces]; + for (let i = bag.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [bag[i], bag[j]] = [bag[j], bag[i]]; + } + this.nextQueue.push(...bag); + } + + hold() { + if (!this.canHold) return; + + [this.piece, this.holdPiece] = [this.holdPiece, this.piece]; + if (!this.piece) this.spawnPiece(); + if (!this.piece) return; + + this.piece.x = Math.floor((COLS - this.piece.shape[0].length) / 2); + this.piece.y = -2; + this.piece.rotationIndex = 0; + this.piece.shape = this.piece.rotations[this.piece.rotationIndex]; + + this.canHold = false; + this.drawHold(); + } + + spawnPiece() { + this.canHold = true; + if (this.nextQueue.length < 7) this.fillBag(); + const type = this.nextQueue.shift()!; + this.piece = new Piece(type); + if (this.collides(this.piece)) { + this.isGameOver = true; + } + + this.drawHold(); + this.drawQueue(); + } + + collides(piece: Piece): boolean { + for (const cell of piece.getCells()) { + if (cell.y >= ROWS) return true; + if (cell.x < 0 || cell.x >= COLS) return true; + if (cell.y >= 0 && this.board[cell.y][cell.x]) return true; + } + return false; + } + + getGhostOffset(piece: Piece): number { + let y: number = 0; + while (true) { + for (const cell of piece.getCells()) { + if ( + cell.y + y >= ROWS || + (cell.y + y >= 0 && this.board[cell.y + y][cell.x]) + ) + return y - 1; + } + + y++; + } + } + + lockPiece() { + if (!this.piece) return; + this.isLocking = false; + let isValid: boolean = false; + for (const cell of this.piece.getCells()) { + if (cell.y >= 0 && cell.y < ROWS && cell.x >= 0 && cell.x < COLS) + this.board[cell.y][cell.x] = cell.val; + if (cell.y > 0) isValid = true; + } + if (!isValid) this.isGameOver = true; + + this.clearLines(); + this.spawnPiece(); + } + + clearLines() { + let linesCleared = 0; + outer: for (let r = ROWS - 1; r >= 0; r--) { + for (let c = 0; c < COLS; c++) if (!this.board[r][c]) continue outer; + + this.board.splice(r, 1); + this.board.unshift(new Array(COLS).fill(0)); + linesCleared++; + r++; + } + + if (linesCleared > 0) { + this.lines += linesCleared; + const points = [0, 40, 100, 300, 1200]; + this.score += (points[linesCleared] || 0) * this.level; + // level up every 10 lines (Fixed Goal System) + const newLevel = Math.floor(this.lines / 10) + 1; + if (newLevel > this.level) { + this.level = newLevel; + this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 75); + } + } + } + + rotatePiece(dir: "cw" | "ccw") { + if (!this.piece) return; + if (this.isLocking && this.lockRotationCount < 15) + this.lockRotationCount++; + // Try wall kicks + const originalIndex = this.piece.rotationIndex; + if (dir === "cw") this.piece.rotateCW(); + else this.piece.rotateCCW(); + const kicks = [0, -1, 1, -2, 2]; + for (const k of kicks) { + this.piece.x += k; + if (!this.collides(this.piece)) return; + this.piece.x -= k; + } + // no kick, revert + this.piece.rotationIndex = originalIndex; + this.piece.shape = this.piece.rotations[originalIndex]; + } + + movePiece(dx: number, dy: number) { + if (!this.piece) return; + this.piece.x += dx; + this.piece.y += dy; + + if (this.collides(this.piece)) { + this.piece.x -= dx; + this.piece.y -= dy; + return false; + } + return true; + } + + hardDrop() { + if (!this.piece) return; + let dropped = 0; + while (this.movePiece(0, 1)) dropped++; + this.score += dropped * 2; + this.lockPiece(); + } + + softDrop() { + if (!this.piece) return; + if (!this.movePiece(0, 1)) return; + else this.score += 1; + } + + keys: Record = {}; + direction: number = 0; + inputDelay = 200; + inputTimestamp = Date.now(); + move: boolean = false; + + inputManager() { + if (this.move || Date.now() > this.inputTimestamp + this.inputDelay) + { + if (this.keys["ArrowLeft"] && !this.keys["ArrowRight"]) + this.movePiece(-1, 0); + else if (!this.keys["ArrowLeft"] && this.keys["ArrowRight"]) + this.movePiece(1, 0); + else if (this.keys["ArrowLeft"] && this.keys["ArrowRight"]) + this.movePiece(this.direction, 0); + this.move = false; + } + } + + removeListeners() { + window.removeEventListener("keydown", (e) => { + this.keys[e.key] = true; + + if (this.isGameOver) return; + + if (e.key === "p" || e.key === "P" || e.key === "Escape") + this.isPaused = !this.isPaused; + + if (this.isPaused) return; + + if (e.key === "ArrowLeft") + { + this.inputTimestamp = Date.now(); + this.direction = -1;//this.movePiece(-1, 0); + this.move = true; + } + else if (e.key === "ArrowRight") + { + this.inputTimestamp = Date.now(); + this.direction = 1;//this.movePiece(1, 0); + this.move = true; + } + else if (e.key === "ArrowDown") this.softDrop(); + else if (e.code === "Space") { + e.preventDefault(); + this.hardDrop(); + } else if (e.key === "Shift" || e.key === "c" || e.key === "C") { + e.preventDefault(); + this.hold(); + } else if (e.key === "x" || e.key === "X" || e.key === "ArrowUp") { + e.preventDefault(); + this.rotatePiece("cw"); + } else if (e.key === "z" || e.key === "Z" || e.key === "Control") { + e.preventDefault(); + this.rotatePiece("ccw"); + } + }); + + document.removeEventListener("keyup", (e) => { + this.keys[e.key] = false; + }); + } + + registerListeners() { + window.addEventListener("keydown", (e) => { + this.keys[e.key] = true; + + if (this.isGameOver) return; + + if (e.key === "p" || e.key === "P" || e.key === "Escape") + this.isPaused = !this.isPaused; + + if (this.isPaused) return; + + if (e.key === "ArrowLeft") + { + this.inputTimestamp = Date.now(); + this.direction = -1;//this.movePiece(-1, 0); + this.move = true; + } + else if (e.key === "ArrowRight") + { + this.inputTimestamp = Date.now(); + this.direction = 1;//this.movePiece(1, 0); + this.move = true; + } + else if (e.key === "ArrowDown") this.softDrop(); + else if (e.code === "Space") { + //e.preventDefault(); + this.hardDrop(); + } else if (e.key === "Shift" || e.key === "c" || e.key === "C") { + this.hold(); + } else if (e.key === "x" || e.key === "X" || e.key === "ArrowUp") { + //e.preventDefault(); + this.rotatePiece("cw"); + } else if (e.key === "z" || e.key === "Z" || e.key === "Control") { + this.rotatePiece("ccw"); + } + }); + + document.addEventListener("keyup", (e) => { + this.keys[e.key] = false; + }); + } + + async loop(timestamp: number) { + if (!view.running) return this.removeListeners(); + if (!this.lastDrop) this.lastDrop = timestamp; + if (!this.isPaused) + { + this.inputManager(); + if (this.isLocking ? timestamp - this.lastDrop > 500 : timestamp - this.lastDrop > this.dropInterval) + { + if (this.isLocking && this.lockRotationCount == this.lockLastRotationCount) + this.lockPiece(); + this.lockLastRotationCount = this.lockRotationCount; + if (!this.movePiece(0, 1)) + { + if (!this.isLocking) + { + this.lockRotationCount = 0; + this.lockLastRotationCount = 0; + this.isLocking = true; + } + } + else if (this.isLocking) + this.lockRotationCount = 0; + this.lastDrop = timestamp; + } + } + this.draw(); + + if (this.isGameOver) + { + if (await isLogged()) + { + let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + fetch(`${user_api}/users/${uuid}/matchHistory?game=tetris`, { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ + "game": "tetris", + "myScore": this.score, + "date": Date.now(), + }), + }); + } + document.getElementById("game-buttons")?.classList.remove("hidden"); + return ; + } + requestAnimationFrame(this.loop.bind(this)); + } + + drawGrid() { + const ctx = this.ctx; + if (!ctx || !this.canvas) + return; + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.strokeStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(14.5% 0 0)" : "oklch(55.6% 0 0)"; + for (let r = 0; r <= ROWS; r++) { + // horizontal lines + ctx.beginPath(); + ctx.moveTo(0, r * BLOCK); + ctx.lineTo(COLS * BLOCK, r * BLOCK); + ctx.stroke(); + } + for (let c = 0; c <= COLS; c++) { + ctx.beginPath(); + ctx.moveTo(c * BLOCK, 0); + ctx.lineTo(c * BLOCK, ROWS * BLOCK); + ctx.stroke(); + } + } + + drawBoard() { + this.drawGrid(); + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const val = this.board[r][c]; + if (val) this.fillBlock(c, r, COLORS[val], this.ctx); + else this.clearBlock(c, r); + } + } + } + + drawPiece() { + if (!this.piece) return; + + for (const cell of this.piece.getCells()) + if (cell.y >= 0) this.fillBlock(cell.x, cell.y, COLORS[cell.val], this.ctx); + + let offset: number = this.getGhostOffset(this.piece); + for (const cell of this.piece.getCells()) + if (cell.y + offset >= 0 && offset > 0) + this.fillGhostBlock(cell.x, cell.y + offset, COLORS[cell.val]); + } + + drawHold() { + if (!this.holdCtx || !this.holdCanvas) return; + + this.holdCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + this.holdCtx.fillRect(0, 0, this.holdCanvas.width, this.holdCanvas.height); + + if (!this.holdPiece) return; + let y: number = 0; + for (const row of this.holdPiece.rotations[0]) { + let x: number = 0; + for (const val of row) { + if (val) + this.fillBlock(x + (4 - this.holdPiece.rotations[0].length)/ 2 + 0.35, y + 0.65, this.canHold ? COLORS[this.holdPiece.findColorIndex()] : ["#8c8c84", "#393934"], this.holdCtx); + x++; + } + y++; + } + } + + drawQueue() { + if (!this.queueCtx || !this.queueCanvas) return ; + + this.queueCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + this.queueCtx.fillRect(0, 0, this.queueCanvas.width, this.queueCanvas.height); + let placement: number = 0; + for (const nextPiece of this.nextQueue.slice(0, 5)) { + let y: number = 0; + for (const row of TETROMINOES[nextPiece][0]) { + let x: number = 0; + for (const val of row) { + if (val) + this.fillBlock(x + (4 - TETROMINOES[nextPiece][0].length) / 2 + 0.25, y + 0.5 + placement * 2.69 - (nextPiece ==="I" ? 0.35 : 0), COLORS[["I", "J", "L", "O", "S", "T", "Z"].indexOf(nextPiece) + 1], this.queueCtx); + x++; + } + y++; + } + placement++; + } + } + + adjustColor(hex: string, amount: number): string { + let color = hex.startsWith('#') ? hex.slice(1) : hex; + const num = parseInt(color, 16); + let r = (num >> 16) + amount; + let g = ((num >> 8) & 0x00FF) + amount; + let b = (num & 0x0000FF) + amount; + r = Math.max(Math.min(255, r), 0); + g = Math.max(Math.min(255, g), 0); + b = Math.max(Math.min(255, b), 0); + return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`; + } + + fillBlock(x: number, y: number, color: string[], ctx: CanvasRenderingContext2D | null) { + if (!ctx) return; + const grad = ctx.createLinearGradient(x * BLOCK, y * BLOCK, x * BLOCK, y * BLOCK + BLOCK); + grad.addColorStop(0, color[0]); + grad.addColorStop(1, color[1]); + ctx.fillStyle = grad; + ctx.fillRect(Math.round(x * BLOCK) + 4, Math.round(y * BLOCK) + 4, BLOCK - 4, BLOCK - 4); + const X = Math.round(x * BLOCK); + const Y = Math.round(y * BLOCK); + const W = BLOCK; + const H = BLOCK; + const S = 4; + + ctx.lineWidth = S; + ctx.beginPath(); + ctx.strokeStyle = color[0]; + ctx.moveTo(X, Y + S / 2); + ctx.lineTo(X + W, Y + S / 2); + ctx.moveTo(X + S / 2, Y); + ctx.lineTo(X + S / 2, Y + H); + ctx.stroke(); + + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[1], -20); + ctx.moveTo(X, Y + H - S / 2); + ctx.lineTo(X + W, Y + H - S / 2); + ctx.moveTo(X + W - S / 2, Y); + ctx.lineTo(X + W - S / 2, Y + H); + ctx.stroke(); + } + fillGhostBlock(x: number, y: number, color: string[]) { + if (!this.ctx) return; + const ctx = this.ctx; + + const X = x * BLOCK; + const Y = y * BLOCK; + const W = BLOCK; + const H = BLOCK; + const S = 4; + + ctx.lineWidth = S; + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[0], -40); + ctx.moveTo(X, Y + S / 2); + ctx.lineTo(X + W, Y + S / 2); + ctx.moveTo(X + S / 2, Y); + ctx.lineTo(X + S / 2, Y + H); + ctx.stroke(); + + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[1], -60); + ctx.moveTo(X, Y + H - S / 2); + ctx.lineTo(X + W, Y + H - S / 2); + ctx.moveTo(X + W - S / 2, Y); + ctx.lineTo(X + W - S / 2, Y + H); + ctx.stroke(); + //ctx.strokeRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); + } + + clearBlock(x: number, y: number) { + if (!this.ctx) return; + const ctx = this.ctx; + ctx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + ctx.fillRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); + } + + drawHUD() { + if (!this.ctx || !this.canvas) return; + const ctx = this.ctx; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillRect(4, 4, 120, 60); + ctx.fillStyle = "#fff"; + ctx.font = "12px Kubasta"; + ctx.fillText(`score: ${this.score}`, 8, 20); + ctx.fillText(`lines: ${this.lines}`, 8, 36); + ctx.fillText(`level: ${this.level}`, 8, 52); + + if (this.isPaused) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, this.canvas.height / 2 - 24, this.canvas.width, 48); + ctx.fillStyle = "#fff"; + ctx.font = "24px Kubasta"; + ctx.textAlign = "center"; + ctx.fillText( + "paused", + this.canvas.width / 2, + this.canvas.height / 2 + 8, + ); + ctx.textAlign = "start"; + } + + if (this.isGameOver) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, this.canvas.height / 2 - 36, this.canvas.width, 72); + ctx.fillStyle = "#fff"; + ctx.font = "28px Kubasta"; + ctx.textAlign = "center"; + ctx.fillText( + "game over", + this.canvas.width / 2, + this.canvas.height / 2 + 8, + ); + ctx.textAlign = "start"; + } + } + + draw() { + if (!this.ctx || !this.canvas) return; + // clear everything + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.ctx.fillStyle = "#000"; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.ctx.strokeStyle = "#111"; + for (let r = 0; r <= ROWS; r++) { + this.ctx.beginPath(); + this.ctx.moveTo(0, r * BLOCK); + this.ctx.lineTo(COLS * BLOCK, r * BLOCK); + this.ctx.stroke(); + } + for (let c = 0; c <= COLS; c++) { + this.ctx.beginPath(); + this.ctx.moveTo(c * BLOCK, 0); + this.ctx.lineTo(c * BLOCK, ROWS * BLOCK); + this.ctx.stroke(); + } + + this.drawBoard(); + this.drawPiece(); + this.drawHUD(); + this.drawQueue(); + setSleepPos(); + } + } + + document.getElementById("game-retry")?.addEventListener("click", () => { document.getElementById("game-buttons")?.classList.add("hidden"); const game = new Game("board"); }); + const game = new Game("board"); + } +} diff --git a/src/front/static/ts/views/TetrisMenu.ts b/src/front/static/ts/views/TetrisMenu.ts new file mode 100644 index 0000000..8646f83 --- /dev/null +++ b/src/front/static/ts/views/TetrisMenu.ts @@ -0,0 +1,42 @@ +import Aview from "./Aview.ts" +import { dragElement } from "./drag.js" +import { setOnekoState } from "../oneko.ts" + +export default class extends Aview { + + constructor() + { + super(); + this.setTitle("knl is trans(cendence)"); + setOnekoState("default"); + } + + async getHTML() { + return ` +
+
+ tetris_game.ts +
+ + + × +
+
+
+

welcome to tetris! :D

+ +
+
+ `; + } + async run() { + dragElement(document.getElementById("window")); + } +} diff --git a/src/front/static/ts/views/TetrisVersus.ts b/src/front/static/ts/views/TetrisVersus.ts new file mode 100644 index 0000000..fbe83d0 --- /dev/null +++ b/src/front/static/ts/views/TetrisVersus.ts @@ -0,0 +1,987 @@ +import Aview from "./Aview.ts"; +import { isLogged, user_api, auth_api } from "../main.js"; +import { dragElement } from "./drag.js"; +import { setOnekoState, setBallPos, setOnekoOffset, setSleepPos } from "../oneko.ts"; + +export default class extends Aview { + running: boolean; + + constructor() { + super(); + this.setTitle("tetris (local match)"); + setOnekoState("tetris"); + this.running = true; + } + + async getHTML() { + return ` +
+
+ tetris_game.ts +
+ + + × +
+
+ + +
+
+

enter the users ids/names

+
+ + +
+ +
+ + +
+
+ `; + } + + async run() { + setSleepPos(); + dragElement(document.getElementById("window")); + const COLS = 10; + const ROWS = 20; + const BLOCK = 30; // pixels per block + + let uuid: string; + let game1: Game; + let game2: Game; + let p1_score: number = 0; + let p2_score: number = 0; + let p1_name: string; + let p2_name: string; + let p1_displayName: string; + let p2_displayName: string; + + const view = this; + + type Cell = number; + + // Tetromino definitions: each piece is an array of rotations, each rotation is a 2D matrix + const TETROMINOES: { [key: string]: number[][][] } = { + I: [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + ], + + [ + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + ], + ], + J: [ + [ + [2, 0, 0], + [2, 2, 2], + [0, 0, 0], + ], + + [ + [0, 2, 2], + [0, 2, 0], + [0, 2, 0], + ], + + [ + [0, 0, 0], + [2, 2, 2], + [0, 0, 2], + ], + + [ + [0, 2, 0], + [0, 2, 0], + [2, 2, 0], + ], + ], + L: [ + [ + [0, 0, 3], + [3, 3, 3], + [0, 0, 0], + ], + + [ + [0, 3, 0], + [0, 3, 0], + [0, 3, 3], + ], + + [ + [0, 0, 0], + [3, 3, 3], + [3, 0, 0], + ], + + [ + [3, 3, 0], + [0, 3, 0], + [0, 3, 0], + ], + ], + O: [ + [ + [4, 4], + [4, 4], + ], + ], + S: [ + [ + [0, 5, 5], + [5, 5, 0], + [0, 0, 0], + ], + + [ + [0, 5, 0], + [0, 5, 5], + [0, 0, 5], + ], + + [ + [0, 0, 0], + [0, 5, 5], + [5, 5, 0], + ], + + [ + [5, 0, 0], + [5, 5, 0], + [0, 5, 0], + ], + ], + T: [ + [ + [0, 6, 0], + [6, 6, 6], + [0, 0, 0], + ], + + [ + [0, 6, 0], + [0, 6, 6], + [0, 6, 0], + ], + + [ + [0, 0, 0], + [6, 6, 6], + [0, 6, 0], + ], + + [ + [0, 6, 0], + [6, 6, 0], + [0, 6, 0], + ], + ], + Z: [ + [ + [7, 7, 0], + [0, 7, 7], + [0, 0, 0], + ], + + [ + [0, 0, 7], + [0, 7, 7], + [0, 7, 0], + ], + + [ + [0, 0, 0], + [7, 7, 0], + [0, 7, 7], + ], + + [ + [0, 7, 0], + [7, 7, 0], + [7, 0, 0], + ], + ], + }; + + const COLORS = [ + [ "#000000", "#000000" ] , // placeholder for 0 + [ "#00d2e1", "#0080a8" ], // I - cyan + [ "#0092e9", "#001fbf" ], // J - blue + [ "#e79700", "#c75700" ], // L - orange + [ "#d8c800", "#8f7700" ], // O - yellow + [ "#59e000", "#038b00" ], // S - green + [ "#de1fdf", "#870087" ], // T - purple + [ "#f06600", "#c10d07" ], // Z - red + [ "#8c8c84", "#393934" ], // garbage - gray + ]; + + class Piece { + shape: number[][]; + rotations: number[][][]; + rotationIndex: number; + x: number; + y: number; + colorIndex: number; + + constructor(public type: string) { + this.rotations = TETROMINOES[type]; + this.rotationIndex = 0; + this.shape = this.rotations[this.rotationIndex]; + this.colorIndex = this.findColorIndex(); + + this.x = Math.floor((COLS - this.shape[0].length) / 2); + this.y = -2; //start on tiles 21 and 22 + } + + findColorIndex() { + for (const row of this.shape) + for (const v of row) + if (v) + return v; + return 1; + } + + rotateCW() { + this.rotationIndex = (this.rotationIndex + 1) % this.rotations.length; + this.shape = this.rotations[this.rotationIndex]; + } + + rotateCCW() { + this.rotationIndex = + (this.rotationIndex - 1 + this.rotations.length) % + this.rotations.length; + this.shape = this.rotations[this.rotationIndex]; + } + + getCells(): { x: number; y: number; val: number }[] { + const cells: { x: number; y: number; val: number }[] = []; + + for (let r = 0; r < this.shape.length; r++) { + for (let c = 0; c < this.shape[r].length; c++) { + const val = this.shape[r][c]; + if (val) cells.push({ x: this.x + c, y: this.y + r, val }); + } + } + return cells; + } + } + + class Game { + board: Cell[][]; + canvas: HTMLCanvasElement | null; + holdCanvas: HTMLCanvasElement | null; + queueCanvas: HTMLCanvasElement | null; + ctx: CanvasRenderingContext2D | null; + holdCtx: CanvasRenderingContext2D | null; + queueCtx: CanvasRenderingContext2D | null; + piece: Piece | null = null; + holdPiece: Piece | null = null; + canHold: boolean = true; + nextQueue: string[] = []; + score: number = 0; + level: number = 1; + lines: number = 0; + garbage: number = 0; + dropInterval: number = 1000; + lastDrop: number = 0; + isLocking: boolean = false; + lockRotationCount: number = 0; + lockLastRotationCount: number = 0; + isGameOver: boolean = false; + isPaused: boolean = false; + id: number; + + constructor(canvasId: string, id: number) { + this.id = id; + const el = document.getElementById( + canvasId + "-board", + ) as HTMLCanvasElement | null; + this.canvas = el; + if (!this.canvas) + throw console.error("no canvas :c"); + this.canvas.width = COLS * BLOCK; + this.canvas.height = ROWS * BLOCK; + const ctx = this.canvas.getContext("2d"); + this.ctx = ctx; + if (!this.ctx) + throw console.error("no ctx D:"); + + this.holdCanvas = document.getElementById(canvasId + "-hold") as HTMLCanvasElement; + this.queueCanvas = document.getElementById(canvasId + "-queue") as HTMLCanvasElement; + if (!this.holdCanvas || !this.queueCanvas) + throw console.error("no canvas :c"); + this.holdCtx = this.holdCanvas.getContext("2d"); + this.queueCtx = this.queueCanvas.getContext("2d"); + if (!this.holdCtx || !this.queueCtx) + return; + + this.holdCtx.clearRect(0, 0, 200, 200); + this.queueCtx.clearRect(0, 0, 500, 500); + + this.board = this.createEmptyBoard(); + if (id == 0) + this.fillBag(); + else + this.nextQueue = game1.nextQueue; + + this.spawnPiece(); + if (id != 0) + this.piece.type = game1.piece.type; + this.registerListeners(); + requestAnimationFrame(this.loop.bind(this)); + } + + createEmptyBoard(): Cell[][] { + const b: Cell[][] = []; + for (let r = 0; r < ROWS; r++) { + const row: Cell[] = new Array(COLS).fill(0); + b.push(row); + } + return b; + } + + fillBag() { + // classic 7-bag randomizer + const pieces = Object.keys(TETROMINOES); + const bag = [...pieces]; + for (let i = bag.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [bag[i], bag[j]] = [bag[j], bag[i]]; + } + this.nextQueue.push(...bag); + } + + hold() { + if (!this.canHold) return; + + [this.piece, this.holdPiece] = [this.holdPiece, this.piece]; + if (!this.piece) this.spawnPiece(); + if (!this.piece) return; + + this.piece.x = Math.floor((COLS - this.piece.shape[0].length) / 2); + this.piece.y = -2; + this.piece.rotationIndex = 0; + this.piece.shape = this.piece.rotations[this.piece.rotationIndex]; + + this.canHold = false; + this.drawHold(); + } + + spawnPiece() { + this.canHold = true; + if (this.nextQueue.length < 7) this.fillBag(); + const type = this.nextQueue.shift()!; + this.piece = new Piece(type); + if (this.collides(this.piece)) { + game1.isGameOver = true; + game2.isGameOver = true; + } + + this.drawHold(); + this.drawQueue(); + } + + collides(piece: Piece): boolean { + for (const cell of piece.getCells()) { + if (cell.y >= ROWS) return true; + if (cell.x < 0 || cell.x >= COLS) return true; + if (cell.y >= 0 && this.board[cell.y][cell.x]) return true; + } + return false; + } + + getGhostOffset(piece: Piece): number { + let y: number = 0; + while (true) { + for (const cell of piece.getCells()) { + if ( + cell.y + y >= ROWS || + (cell.y + y >= 0 && this.board[cell.y + y][cell.x]) + ) + return y - 1; + } + + y++; + } + } + + lockPiece() { + if (!this.piece) return; + this.isLocking = false; + let isValid: boolean = false; + for (const cell of this.piece.getCells()) { + if (cell.y >= 0 && cell.y < ROWS && cell.x >= 0 && cell.x < COLS) + this.board[cell.y][cell.x] = cell.val; + if (cell.y > 0) isValid = true; + } + if (!isValid) + { + this.id == 0 ? p2_score++ : p1_score++; + game1.isGameOver = true; + game2.isGameOver = true; + } + + if (this.garbage) + { + const empty_spot = Math.floor(Math.random() * 10); + while (this.garbage) + { + //if () // if anything else than 0 on top, die >:3 + this.board.shift(); + this.board.push(Array(COLS).fill(8)); + this.board[19][empty_spot] = 0; + this.garbage--; + } + } + this.clearLines(); + this.spawnPiece(); + } + + addGarbage(lines: number) { + this.garbage += lines; + } + + clearLines() { + let linesCleared = 0; + outer: for (let r = ROWS - 1; r >= 0; r--) { + for (let c = 0; c < COLS; c++) if (!this.board[r][c]) continue outer; + + this.board.splice(r, 1); + this.board.unshift(new Array(COLS).fill(0)); + linesCleared++; + r++; + } + + if (linesCleared > 0) { + this.lines += linesCleared; + const points = [0, 40, 100, 300, 1200]; + this.score += (points[linesCleared] || 0) * this.level; + // level up every 10 lines (Fixed Goal System) + const newLevel = Math.floor(this.lines / 10) + 1; + if (newLevel > this.level) { + this.level = newLevel; + this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 75); + } + + if (this.garbage) + { + while (linesCleared) + { + this.garbage--; + linesCleared--; + if (!this.garbage) + break; + } + } + if (this.id == 0 && linesCleared) + game2.addGarbage(linesCleared < 4 ? linesCleared - 1 : linesCleared); + else + game1.addGarbage(linesCleared < 4 ? linesCleared - 1 : linesCleared); + } + } + + rotatePiece(dir: "cw" | "ccw") { + if (!this.piece) return; + if (this.isLocking && this.lockRotationCount < 15) + this.lockRotationCount++; + // Try wall kicks + const originalIndex = this.piece.rotationIndex; + if (dir === "cw") this.piece.rotateCW(); + else this.piece.rotateCCW(); + const kicks = [0, -1, 1, -2, 2]; + for (const k of kicks) { + this.piece.x += k; + if (!this.collides(this.piece)) return; + this.piece.x -= k; + } + // no kick, revert + this.piece.rotationIndex = originalIndex; + this.piece.shape = this.piece.rotations[originalIndex]; + } + + movePiece(dx: number, dy: number) { + if (!this.piece) return; + this.piece.x += dx; + this.piece.y += dy; + + if (this.collides(this.piece)) { + this.piece.x -= dx; + this.piece.y -= dy; + return false; + } + return true; + } + + hardDrop() { + if (!this.piece) return; + let dropped = 0; + while (this.movePiece(0, 1)) dropped++; + this.score += dropped * 2; + this.lockPiece(); + } + + softDrop() { + if (!this.piece) return; + if (!this.movePiece(0, 1)) return; + else this.score += 1; + } + + keys: Record = {}; + direction: number = 0; + inputDelay = 200; + inputTimestamp = Date.now(); + move: boolean = false; + + inputManager() { + const left = this.id === 0 ? this.keys["KeyA"] : this.keys["Numpad4"] + const right = this.id === 0 ? this.keys["KeyD"] : this.keys["Numpad6"] + if (this.move || Date.now() > this.inputTimestamp + this.inputDelay) + { + if (left && !right) + this.movePiece(-1, 0); + else if (!left && right) + this.movePiece(1, 0); + else if (left && right) + this.movePiece(this.direction, 0); + this.move = false; + } + + /*if (this.keys["ArrowDown"]) + this.softDrop();*/ + } + + registerListeners() { + window.addEventListener("keydown", (e) => { + this.keys[e.code] = true; + + if (this.isGameOver) return; + + if (e.key === "p" || e.key === "P" || e.key === "Escape") + this.isPaused = !this.isPaused; + + if (this.isPaused) return; + + if (this.id === 0 ? e.code === "KeyA" : e.code === "Numpad4") + { + this.inputTimestamp = Date.now(); + this.direction = -1;//this.movePiece(-1, 0); + this.move = true; + } + else if (this.id === 0 ? e.code === "KeyD" : e.code === "Numpad6") + { + this.inputTimestamp = Date.now(); + this.direction = 1;//this.movePiece(1, 0); + this.move = true; + } + else if (this.id === 0 ? e.code === "KeyS" : e.code === "Numpad5") this.softDrop(); + else if (this.id === 0 ? e.code === "Space" : e.code === "Numpad0") { + //e.preventDefault(); + this.hardDrop(); + } else if (this.id === 0 ? e.code === "ShiftLeft" : e.code === "NumpadEnter") { + //e.preventDefault(); + this.hold(); + } else if (this.id === 0 ? (e.code === "KeyE" || e.code === "KeyW") : (e.code === "Numpad9" || e.code === "Numpad8")) { + //e.preventDefault(); + this.rotatePiece("cw"); + } else if (this.id === 0 ? (e.code === "KeyQ" || e.code === "ControlLeft") : e.code === "Numpad7") { + //e.preventDefault(); + this.rotatePiece("ccw"); + } + }); + + document.addEventListener("keyup", (e) => { + this.keys[e.code] = false; + }); + } + + async loop(timestamp: number) { + if (!view.running) return; + if (!this.lastDrop) this.lastDrop = timestamp; + if (!this.isPaused) + { + this.inputManager(); + if (this.isLocking ? timestamp - this.lastDrop > 500 : timestamp - this.lastDrop > this.dropInterval) + { + if (this.isLocking && this.lockRotationCount == this.lockLastRotationCount) + this.lockPiece(); + this.lockLastRotationCount = this.lockRotationCount; + if (!this.movePiece(0, 1)) + { + if (!this.isLocking) + { + this.lockRotationCount = 0; + this.lockLastRotationCount = 0; + this.isLocking = true; + } + } + else if (this.isLocking) + this.lockRotationCount = 0; + this.lastDrop = timestamp; + } + } + this.draw(); + + if (this.isGameOver) + { + if (p1_score != 3 && p2_score != 3) + { + if (this.id == 0) + { + game1 = new Game("board1", 0); + game2 = new Game("board2", 1); + } + return ; + } + if (await isLogged() && this.id == 0) + { + let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + fetch(`${user_api}/users/${uuid}/matchHistory?game=tetris`, { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ + "game": "tetris", + "opponent": p2_name, + "myScore": p1_score, + "opponentScore": p2_score, + "date": Date.now(), + }), + }); + } + document.getElementById("game-buttons")?.classList.remove("hidden"); + return ; + } + requestAnimationFrame(this.loop.bind(this)); + } + + drawGrid() { + const ctx = this.ctx; + if (!ctx || !this.canvas) + return; + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.strokeStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(14.5% 0 0)" : "oklch(55.6% 0 0)"; + for (let r = 0; r <= ROWS; r++) { + // horizontal lines + ctx.beginPath(); + ctx.moveTo(0, r * BLOCK); + ctx.lineTo(COLS * BLOCK, r * BLOCK); + ctx.stroke(); + } + for (let c = 0; c <= COLS; c++) { + ctx.beginPath(); + ctx.moveTo(c * BLOCK, 0); + ctx.lineTo(c * BLOCK, ROWS * BLOCK); + ctx.stroke(); + } + } + + drawBoard() { + this.drawGrid(); + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const val = this.board[r][c]; + if (val) this.fillBlock(c, r, COLORS[val], this.ctx); + else this.clearBlock(c, r); + } + } + } + + drawPiece() { + if (!this.piece) return; + + for (const cell of this.piece.getCells()) + if (cell.y >= 0) this.fillBlock(cell.x, cell.y, COLORS[cell.val], this.ctx); + + let offset: number = this.getGhostOffset(this.piece); + for (const cell of this.piece.getCells()) + if (cell.y + offset >= 0 && offset > 0) + this.fillGhostBlock(cell.x, cell.y + offset, COLORS[cell.val]); + } + + drawHold() { + if (!this.holdCtx || !this.holdCanvas) return; + + this.holdCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + this.holdCtx.fillRect(0, 0, this.holdCanvas.width, this.holdCanvas.height); + + if (!this.holdPiece) return; + let y: number = 0; + for (const row of this.holdPiece.rotations[0]) { + let x: number = 0; + for (const val of row) { + if (val) + this.fillBlock(x + (4 - this.holdPiece.rotations[0].length)/ 2 + 0.35, y + 0.5, this.canHold ? COLORS[this.holdPiece.findColorIndex()] : COLORS[8], this.holdCtx); + x++; + } + y++; + } + } + + drawQueue() { + if (!this.queueCtx || !this.queueCanvas) return ; + + this.queueCtx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + this.queueCtx.fillRect(0, 0, this.queueCanvas.width, this.queueCanvas.height); + let placement: number = 0; + for (const nextPiece of this.nextQueue.slice(0, 5)) { + let y: number = 0; + for (const row of TETROMINOES[nextPiece][0]) { + let x: number = 0; + for (const val of row) { + if (val) + this.fillBlock(x + (4 - TETROMINOES[nextPiece][0].length) / 2 + 0.25, y + 0.5 + placement * 2.69 - (nextPiece ==="I" ? 0.35 : 0), COLORS[["I", "J", "L", "O", "S", "T", "Z"].indexOf(nextPiece) + 1], this.queueCtx); + x++; + } + y++; + } + placement++; + } + } + + adjustColor(hex: string, amount: number): string { + let color = hex.startsWith('#') ? hex.slice(1) : hex; + const num = parseInt(color, 16); + let r = (num >> 16) + amount; + let g = ((num >> 8) & 0x00FF) + amount; + let b = (num & 0x0000FF) + amount; + r = Math.max(Math.min(255, r), 0); + g = Math.max(Math.min(255, g), 0); + b = Math.max(Math.min(255, b), 0); + return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`; + } + + fillBlock(x: number, y: number, color: string[], ctx: CanvasRenderingContext2D | null) { + if (!ctx) return; + const grad = ctx.createLinearGradient(x * BLOCK, y * BLOCK, x * BLOCK, y * BLOCK + BLOCK); + grad.addColorStop(0, color[0]); + grad.addColorStop(1, color[1]); + ctx.fillStyle = grad; + ctx.fillRect(Math.round(x * BLOCK) + 4, Math.round(y * BLOCK) + 4, BLOCK - 4, BLOCK - 4); + const X = Math.round(x * BLOCK); + const Y = Math.round(y * BLOCK); + const W = BLOCK; + const H = BLOCK; + const S = 4; + + ctx.lineWidth = S; + ctx.beginPath(); + ctx.strokeStyle = color[0]; + ctx.moveTo(X, Y + S / 2); + ctx.lineTo(X + W, Y + S / 2); + ctx.moveTo(X + S / 2, Y); + ctx.lineTo(X + S / 2, Y + H); + ctx.stroke(); + + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[1], -20); + ctx.moveTo(X, Y + H - S / 2); + ctx.lineTo(X + W, Y + H - S / 2); + ctx.moveTo(X + W - S / 2, Y); + ctx.lineTo(X + W - S / 2, Y + H); + ctx.stroke(); + } + fillGhostBlock(x: number, y: number, color: string[]) { + if (!this.ctx) return; + const ctx = this.ctx; + + const X = x * BLOCK; + const Y = y * BLOCK; + const W = BLOCK; + const H = BLOCK; + const S = 4; + + ctx.lineWidth = S; + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[0], -40); + ctx.moveTo(X, Y + S / 2); + ctx.lineTo(X + W, Y + S / 2); + ctx.moveTo(X + S / 2, Y); + ctx.lineTo(X + S / 2, Y + H); + ctx.stroke(); + + ctx.beginPath(); + ctx.strokeStyle = this.adjustColor(color[1], -60); + ctx.moveTo(X, Y + H - S / 2); + ctx.lineTo(X + W, Y + H - S / 2); + ctx.moveTo(X + W - S / 2, Y); + ctx.lineTo(X + W - S / 2, Y + H); + ctx.stroke(); + //ctx.strokeRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); + } + + clearBlock(x: number, y: number) { + if (!this.ctx) return; + const ctx = this.ctx; + ctx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? "oklch(20.5% 0 0)" : "oklch(70.8% 0 0)"; + ctx.fillRect(x * BLOCK + 1, y * BLOCK + 1, BLOCK - 2, BLOCK - 2); + } + + drawHUD() { + if (!this.ctx || !this.canvas) return; + const ctx = this.ctx; + + if (this.garbage) + { + ctx.fillStyle ="red"; + ctx.fillRect(0, this.canvas.height - BLOCK * this.garbage, 6, BLOCK * this.garbage); + } + + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillRect(4, 4, 120, 60); + ctx.fillStyle = "#fff"; + ctx.font = "12px Kubasta"; + ctx.fillText(`${this.id == 0 ? p1_displayName : p2_displayName}: ${this.id == 0 ? p1_score : p2_score}`, 8, 20); + ctx.fillText(`score: ${this.score}`, 8, 36); + ctx.fillText(`lines: ${this.lines}`, 8, 52); + + if (this.isPaused) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, this.canvas.height / 2 - 24, this.canvas.width, 48); + ctx.fillStyle = "#fff"; + ctx.font = "24px Kubasta"; + ctx.textAlign = "center"; + ctx.fillText( + "paused", + this.canvas.width / 2, + this.canvas.height / 2 + 8, + ); + ctx.textAlign = "start"; + } + + if (this.isGameOver) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, this.canvas.height / 2 - 36, this.canvas.width, 72); + ctx.fillStyle = "#fff"; + ctx.font = "28px Kubasta"; + ctx.textAlign = "center"; + ctx.fillText( + "game over", + this.canvas.width / 2, + this.canvas.height / 2 + 8, + ); + ctx.textAlign = "start"; + } + } + + draw() { + if (!this.ctx || !this.canvas) return; + // clear everything + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.ctx.fillStyle = "#000"; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.ctx.strokeStyle = "#111"; + for (let r = 0; r <= ROWS; r++) { + this.ctx.beginPath(); + this.ctx.moveTo(0, r * BLOCK); + this.ctx.lineTo(COLS * BLOCK, r * BLOCK); + this.ctx.stroke(); + } + for (let c = 0; c <= COLS; c++) { + this.ctx.beginPath(); + this.ctx.moveTo(c * BLOCK, 0); + this.ctx.lineTo(c * BLOCK, ROWS * BLOCK); + this.ctx.stroke(); + } + + this.drawBoard(); + this.drawPiece(); + this.drawHUD(); + this.drawQueue(); + setSleepPos(); + } + } + + document.getElementById("game-retry")?.addEventListener("click", () => { document.getElementById("game-buttons")?.classList.add("hidden"); game1 = new Game("board1", 0); game2 = new Game("board2", 1); }); + + let p1_input: HTMLInputElement = document.getElementById("player1") as HTMLInputElement; + let p2_input: HTMLInputElement = document.getElementById("player2") as HTMLInputElement; + + p2_input.value = "player 2"; + if (await isLogged()) + { + uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + p1_input.value = uuid; + p1_input.readOnly = true; + } + else + p1_input.value = "player 1"; + + document.getElementById("game-start")?.addEventListener("click", async () => { + let p1_isvalid = true; + let p2_isvalid = true; + if (await isLogged()) { + const p1_req = await fetch(`${user_api}/users/${p1_input.value}`, { + method: "GET", + credentials: "include", + }); + const p2_req = await fetch(`${user_api}/users/${p2_input.value}`, { + method: "GET", + credentials: "include", + }); + if (p1_req.status != 200) + p1_isvalid = false; + else + p1_displayName = (await p1_req.json()).displayName; + + if (p2_req.status != 200) + p2_isvalid = false; + else + p2_displayName = (await p2_req.json()).displayName; + } + else + p1_isvalid = p2_isvalid = false; + + p1_name = p1_input.value; + p2_name = p2_input.value; + if (!p1_isvalid) + p1_displayName = p1_name; + if (!p2_isvalid) + p2_displayName = p2_name; + + p1_displayName = p1_displayName.length > 16 ? p1_displayName.substring(0, 16) + "." : p1_displayName; + p2_displayName = p2_displayName.length > 16 ? p2_displayName.substring(0, 16) + "." : p2_displayName; + + document.getElementById("player-inputs").remove(); + document.getElementById("game-boards").classList.remove("hidden"); + game1 = new Game("board1", 0); + game2 = new Game("board2", 1); + }); + + } +} diff --git a/src/front/static/ts/views/TotpEnable.ts b/src/front/static/ts/views/TotpEnable.ts new file mode 100644 index 0000000..51efed2 --- /dev/null +++ b/src/front/static/ts/views/TotpEnable.ts @@ -0,0 +1,110 @@ +import { navigationManager, user_api, auth_api } from "../main.ts"; +import { dragElement } from "./drag.ts"; + +async function totpVerify() { + const code = (document.getElementById("totpPin") as HTMLInputElement).value; + const data_req = await fetch(auth_api + '/2fa/verify', { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: code + }) + }) + + if (data_req.status === 200) { + navigationManager("/settings"); + } else if (data_req.status === 401 || data_req.status === 400) { + const popup_content = document.getElementById("2fa-enable-content"); + + if (!document.getElementById("error-totp")) { + const error = document.createElement("p"); + error.id = "error-totp"; + error.classList.add("text-red-700", "dark:text-red-500", "text-center"); + error.innerHTML = (await data_req.json()).error; + + popup_content?.appendChild(error) + } else { + const error = document.getElementById("error-totp") as HTMLParagraphElement; + error.innerHTML = (await data_req.json()).error; + } + } else { + console.log("Unexpected error") + } +} + +export async function totpEnablePopup(username: String, secret: String, url: String) { + const popup: HTMLDivElement = document.createElement("div"); + popup.id = "2fa-enable-popup"; + popup.classList.add("z-10", "absolute", "default-border"); + const header = popup.appendChild(document.createElement("div"));; + header.classList.add("bg-linear-to-r", "from-orange-200", "to-orange-300", "flex", "flex-row", "min-w-35", "justify-between", "px-2"); + header.id = "2fa-enable-popup-header"; + header.appendChild(document.createElement("span")).innerText = "2fa_enable.ts"; + const btn = header.appendChild(document.createElement("button")); + btn.innerText = " × "; + btn.onclick = () => { document.getElementById("2fa-enable-popup")?.remove(); }; + + const popup_content: HTMLSpanElement = popup.appendChild(document.createElement("div")); + popup_content.id = "2fa-enable-content"; + popup_content.classList.add("flex", "flex-col", "bg-neutral-200", "dark:bg-neutral-800", "p-6", "pt-4", "text-neutral-900", "dark:text-white", "space-y-4"); + + const qrDivTOTP = document.createElement("div"); + qrDivTOTP.classList.add("flex", "justify-center"); + + const qrCodeTOTP = document.createElement("img"); + qrCodeTOTP.id = "qrCodeTOTP"; + qrCodeTOTP.src = `https://api.qrserver.com/v1/create-qr-code/?margin=10&size=512x512&data=${url}`; + qrCodeTOTP.classList.add("w-60"); + qrDivTOTP.appendChild(qrCodeTOTP); + + const secretText = document.createElement("p"); + secretText.innerHTML = `key:
${secret}
`; + secretText.classList.add("text-center") + + const tokenInput = document.createElement("input"); + tokenInput.type = "tel"; + tokenInput.id = "totpPin"; + tokenInput.name = "totpPin"; + tokenInput.placeholder = "TOTP code"; + tokenInput.required = true; + tokenInput.autocomplete = "off"; + tokenInput.pattern = "[0-9]*"; + tokenInput.setAttribute("inputmode", "numeric"); + tokenInput.classList.add("bg-white", "text-neutral-900", "w-full", "px-4", "py-2", "input-border"); + + const tokenSubmit = document.createElement("button"); + tokenSubmit.type = "submit"; + tokenSubmit.classList.add("default-button", "w-full"); + tokenSubmit.id = "totp-submit"; + tokenSubmit.innerHTML = "submit"; + + const hr = document.createElement("hr"); + hr.classList.add("my-2", "w-full", "reverse-border"); + + const t = document.createElement("h2"); + t.innerHTML = "hey " + username + + ` you are trying to add 2fa
+ just add the following to your app and enter the code bellow ↓ + `; + t.classList.add("text-center") + + document.getElementById("app")?.appendChild(popup); + + const form = document.createElement("form"); + form.method = "dialog"; + form.classList.add("space-y-4"); + form.appendChild(tokenInput); + form.appendChild(tokenSubmit); + + popup_content.appendChild(t) + popup_content.appendChild(qrDivTOTP); + popup_content.appendChild(secretText); + popup_content.appendChild(hr) + popup_content.appendChild(form); + dragElement(document.getElementById("2fa-enable-popup")); + + document.getElementById("totp-submit")?.addEventListener("click", totpVerify) +} diff --git a/src/front/static/ts/views/TournamentMenu.ts b/src/front/static/ts/views/TournamentMenu.ts new file mode 100644 index 0000000..03c246e --- /dev/null +++ b/src/front/static/ts/views/TournamentMenu.ts @@ -0,0 +1,544 @@ +import Aview from "./Aview.ts" +import { isLogged, user_api } from "../main.js" +import { dragElement } from "./drag.ts"; +import { setOnekoState, setBallPos, setOnekoOffset } from "../oneko.ts" + +export default class extends Aview { + running: boolean; + + constructor() + { + super(); + this.setTitle("Tournament"); + setOnekoState("default"); + this.running = true; + } + + async getHTML() { + return ` +
+
+ pong_game.ts +
+ + + × +
+
+ +
+
+

how many players ?

+
+ + +
+
+
+ + + + +
+
+ `; + } + + async runGame(p1_id: number, p2_id: number, players: string[]): Promise { + return new Promise(async (resolve) => { + //console.log(p1_id, p2_id, players, players[p1_id], players[p2_id]); + let p1_name = players[p1_id]; + let p2_name = players[p2_id]; + + let uuid: string; + let start: number = 0; + let elapsed: number; + + let game_playing: boolean = false; + let match_over: boolean = false; + let p1_score: number = 0; + let p2_score: number = 0; + + let p1_displayName: string; + let p2_displayName: string; + + let countdown: number = 3; + let countdownTimer: number = 0; + + let canvas: HTMLCanvasElement; + let ctx: CanvasRenderingContext2D; + + const paddleOffset: number = 15; + const paddleHeight: number = 100; + const paddleWidth: number = 10; + const ballSize: number = 10; + + const paddleSpeed: number = 727 * 0.69; + let leftPaddleY: number; + let rightPaddleY: number; + let ballX: number; + let ballY: number; + let ballSpeed: number = 200; + let ballSpeedX: number = 300; + let ballSpeedY: number = 10; + + const keys: Record = {}; + + document.addEventListener("keydown", e => { keys[e.key] = true; }); + document.addEventListener("keyup", e => { keys[e.key] = false; }); + + function movePaddles() { + if ((keys["w"] || keys["W"]) && leftPaddleY > 0) + leftPaddleY -= paddleSpeed * elapsed; + if ((keys["s"] || keys["S"]) && leftPaddleY < canvas.height - paddleHeight) + leftPaddleY += paddleSpeed * elapsed; + if (keys["ArrowUp"] && rightPaddleY > 0) + rightPaddleY -= paddleSpeed * elapsed; + if (keys["ArrowDown"] && rightPaddleY < canvas.height - paddleHeight) + rightPaddleY += paddleSpeed * elapsed; + } + + function getBounceVelocity(paddleY: number) { + const paddleCenterY = paddleY + paddleHeight / 2; + + let n = (ballY - paddleCenterY) / (paddleHeight / 2); + n = Math.max(-1, Math.min(1, n)); + let theta = n * ((75 * Math.PI) / 180); + ballSpeedY = ballSpeed * Math.sin(theta); + } + + async function moveBall() { + let length = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); + let scale = ballSpeed / length; + ballX += (ballSpeedX * scale) * elapsed; + ballY += (ballSpeedY * scale) * elapsed; + + if (ballY <= 0 || ballY >= canvas.height - ballSize) + ballSpeedY *= -1; + + if (ballX <= paddleWidth + paddleOffset && ballX >= paddleOffset && + ballY > leftPaddleY && ballY < leftPaddleY + paddleHeight) { + ballSpeedX *= -1; + ballX = paddleWidth + paddleOffset; + getBounceVelocity(leftPaddleY); + ballSpeed += 10; + } + + if (ballX >= canvas.width - paddleWidth - ballSize - paddleOffset && ballX <= canvas.width - ballSize - paddleOffset && + ballY > rightPaddleY && ballY < rightPaddleY + paddleHeight) { + ballSpeedX *= -1; + ballX = canvas.width - paddleWidth - ballSize - paddleOffset; + getBounceVelocity(rightPaddleY); + ballSpeed += 10; + } + + // scoring + if (ballX < 0 || ballX > canvas.width - ballSize) { + setOnekoState("default"); + game_playing = false; + if (ballX < 0) + p2_score++; + else + p1_score++; + + if (p1_score === 3 || p2_score === 3) { + if (await isLogged()) { + let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + fetch(`${user_api}/users/${uuid}/matchHistory?game=pong`, { + method: "POST", + headers: { "Content-Type": "application/json", }, + credentials: "include", + body: JSON.stringify({ + "game": "pong", + "opponent": p2_name, + "myScore": p1_score, + "opponentScore": p2_score, + "date": Date.now(), + }), + }); + } + match_over = true; + resolve(p1_score == 3 ? p1_id : p2_id); + } + else { + countdown = 3; + countdownTimer = performance.now(); + } + + ballX = canvas.width / 2; + ballY = canvas.height / 2; + ballSpeed = 200; + ballSpeedX = 300 * ((ballSpeedX > 0) ? 1 : -1); + ballSpeedY = 10; + ballSpeedX = -ballSpeedX; + leftPaddleY = canvas.height / 2 - paddleHeight / 2; + rightPaddleY = canvas.height / 2 - paddleHeight / 2; + } + setBallPos(ballX, ballY); + } + + function draw() { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = "white"; + ctx.beginPath(); + ctx.setLineDash([5, 10]); + ctx.moveTo(canvas.width / 2, 0); + ctx.lineTo(canvas.width / 2, canvas.height); + ctx.stroke(); + + ctx.fillStyle = "white"; + ctx.fillRect(paddleOffset, leftPaddleY, paddleWidth, paddleHeight); + ctx.fillRect(canvas.width - paddleWidth - paddleOffset, rightPaddleY, paddleWidth, paddleHeight); + + ctx.fillStyle = "white"; + if (game_playing) + ctx.fillRect(ballX, ballY, ballSize, ballSize); + + ctx.font = "24px Kubasta"; + let text_score = `${p1_score} - ${p2_score}`; + ctx.fillText(text_score, canvas.width / 2 - (ctx.measureText(text_score).width / 2), 25); + ctx.fillText(p1_displayName, canvas.width / 4 - (ctx.measureText(p1_name).width / 2), 45); + ctx.fillText(p2_displayName, (canvas.width / 4 * 3) - (ctx.measureText(p2_name).width / 2), 45); + + if (match_over) { + ctx.font = "32px Kubasta"; + const winner = `${p1_score > p2_score ? p1_name : p2_name} won :D`; + ctx.fillText(winner, canvas.width / 2 - (ctx.measureText(winner).width / 2), canvas.height / 2 + 16); + document.getElementById("game-buttons")?.classList.remove("hidden"); + } + } + + function startCountdown() { + const now = performance.now(); + if (countdown > 0) { + if (now - countdownTimer >= 500) { + countdown--; + countdownTimer = now; + } + ctx.font = "48px Kubasta"; + ctx.fillText(countdown.toString(), canvas.width / 2 - 10, canvas.height / 2 + 24); + } + else if (countdown === 0) { + ctx.font = "48px Kubasta"; + ctx.fillText("Go!", canvas.width / 2 - 30, canvas.height / 2 + 24); + setTimeout(() => { + game_playing = true; + countdown = -1; + }, 500); + } + } + + const gameLoop = async (timestamp: number) => { + elapsed = (timestamp - start) / 1000; + start = timestamp; + if (game_playing) { + movePaddles(); + await moveBall(); + } + draw(); + if (!game_playing) + startCountdown(); + if (this.running) + requestAnimationFrame(gameLoop); + }; + + + document.getElementById("game-retry")?.addEventListener("click", () => { + setOnekoState("pong"); + document.getElementById("game-buttons")?.classList.add("hidden"); + game_playing = false; + match_over = false; + p1_score = 0; + p2_score = 0; + + countdown = 3; + countdownTimer = performance.now(); + }); + + let p1_isvalid = true; + let p2_isvalid = true; + if (await isLogged()) { + const p1_req = await fetch(`${user_api}/users/${p1_name}`, { + method: "GET", + credentials: "include", + }); + const p2_req = await fetch(`${user_api}/users/${p2_name}`, { + method: "GET", + credentials: "include", + }); + + if (p1_req.status == 200) + p1_displayName = (await p1_req.json()).displayName; + else + p1_displayName = p1_name; + + if (p2_req.status == 200) + p2_displayName = (await p2_req.json()).displayName; + else + p2_displayName = p2_name; + } + else + { + p1_displayName = p1_name; + p2_displayName = p2_name; + } + + p1_displayName = p1_displayName.length > 16 ? p1_displayName.substring(0, 16) + "." : p1_displayName; + p2_displayName = p2_displayName.length > 16 ? p2_displayName.substring(0, 16) + "." : p2_displayName; + p1_name = p1_name.length > 16 ? p1_name.substring(0, 16) + "." : p1_name; + p2_name = p2_name.length > 16 ? p2_name.substring(0, 16) + "." : p2_name; + document.getElementById("tournament-ui")?.classList.add("hidden"); + + canvas = document.createElement("canvas"); + canvas.id = "gameCanvas"; + canvas.classList.add("reverse-border"); + + document.getElementById("main-div")?.prepend(canvas); + + ctx = canvas.getContext("2d", { alpha: false }) as CanvasRenderingContext2D; + ctx.canvas.width = 600; + ctx.canvas.height = 600; + + leftPaddleY = canvas.height / 2 - paddleHeight / 2; + rightPaddleY = canvas.height / 2 - paddleHeight / 2; + ballX = canvas.width / 2; + ballY = canvas.height / 2; + + setOnekoState("pong"); + setOnekoOffset(); + requestAnimationFrame(gameLoop); + }); + } + + waitForUserClick(buttonId: string): Promise { + return new Promise((resolve) => { + const button = document.getElementById(buttonId); + if (!button) return resolve(); // failsafe if no button + + const handler = () => { + button.removeEventListener("click", handler); + resolve(); + }; + button.addEventListener("click", handler); + }); + } + + tournament_state: number[][]; + i: number = 0; + space: number; + + updateBracketDisplay(tournament: number[][], players: string[]) { + for (let i of Array(tournament[0].length).keys()) + this.tournament_state[this.i][i] = tournament[0][i]; + for (let i of Array(tournament[1].length).keys()) + { + console.log(this.tournament_state, this.i, i); + this.tournament_state[this.i + 1][i] = tournament[1][i]; + } + this.i++; + const container = document.getElementById("bracket-announcement"); + if (!container) return; + container.innerHTML = ""; // clear old bracket + + const bracketWrapper = document.createElement("div"); + bracketWrapper.className = "flex space-x-8 overflow-x-auto"; + + // replicate generateBracket() spacing logic + let previousPadding = 4; + + for (let round = 0; round < this.tournament_state.length; round++) { + const roundColumn = document.createElement("div"); + + if (round === 0) { + roundColumn.className = `flex flex-col mt-${this.space} space-y-4`; + } else { + previousPadding = previousPadding * 2 + 10; + roundColumn.className = `flex flex-col justify-center space-y-${previousPadding}`; + } + + // each player slot or winner + for (let i = 0; i < this.tournament_state[round].length; i++) { + const playerIndex = this.tournament_state[round][i]; + const name = + playerIndex !== undefined && playerIndex !== null + ? players[playerIndex] + : ""; + + const cell = document.createElement("div"); + cell.className = + "w-32 h-10 flex items-center justify-center bg-white text-center text-sm input-border"; + cell.textContent = name || ""; + roundColumn.appendChild(cell); + } + + bracketWrapper.appendChild(roundColumn); + } + + container.appendChild(bracketWrapper); + } + + async run() { + dragElement(document.getElementById("window")); + const generateBracket = async (playerCount: number) => { + this.tournament_state = []; + let initPlayerCount = playerCount; + document.getElementById("bracket").innerHTML = ""; + + const rounds: number = Math.ceil(Math.log2(playerCount)); + const totalSlots: number = 2 ** rounds; + let odd: number = 0; + let notPowPlayersCount: number = 0; + let tournament: number[][] = []; + + if ((playerCount & (playerCount - 1)) != 0) + notPowPlayersCount = playerCount - (2 ** Math.floor(Math.log2(playerCount))); + + + let initialPlayers = Array.from({ length: 2 ** Math.floor(Math.log2(playerCount))}, (_, i) => `player ${i + 1}`); + playerCount = 2 ** Math.floor(Math.log2(playerCount)); + + const bracketWrapper = document.createElement("div"); + bracketWrapper.className = "flex space-x-8 overflow-x-auto"; + + // Round 0: Player input column + const playerInputColumn = document.createElement("div"); + this.space = (notPowPlayersCount + odd) * 28; + playerInputColumn.className = `flex flex-col mt-${(notPowPlayersCount + odd) * 28} space-y-4`; + + tournament.push([]); + this.tournament_state.push([]); + initialPlayers.forEach((name, i) => { + const input = document.createElement("input"); + input.type = "text"; + input.id = `playerName${i}`; + input.value = name; + input.placeholder = name; + if (i == 0) + { + isLogged().then((value) => { + if (value) { + let uuid = document.cookie.match(new RegExp('(^| )' + "uuid" + '=([^;]+)'))[2]; + input.value = uuid; + input.readOnly = true; + } + }); + } + input.className = "w-32 h-10 p-2 text-sm bg-white disabled:bg-gray-200 input-border"; + playerInputColumn.appendChild(input); + tournament[0].push(i); + this.tournament_state[0].push(-1); + }); + + bracketWrapper.appendChild(playerInputColumn); + + let currentRound = initialPlayers; + let previousPadding = 4; + tournament.push([]); + for (let round = 1; round <= rounds; round++) + { + this.tournament_state.push([]); + const roundColumn = document.createElement("div"); + previousPadding = previousPadding * 2 + 10 + roundColumn.className = `flex flex-col justify-center space-y-${previousPadding}`; + + const nextRound: string[] = []; + + while (notPowPlayersCount) { + tournament[1].push(playerCount); + this.tournament_state[1].push(-1); + const input = document.createElement("input"); + input.type = "text"; + input.id = `playerName${playerCount}`; + input.value = `player ${playerCount + 1}`; + input.placeholder = `player ${++playerCount}`; + input.className = + "w-32 h-10 p-2 text-sm bg-white disabled:bg-gray-200 input-border"; + roundColumn.appendChild(input); + --notPowPlayersCount; + nextRound.push(""); + } + + for (let i = 0; i < currentRound.length; i += 2) + { + const p1 = currentRound[i]; + const p2 = currentRound[i + 1]; + + const matchDiv = document.createElement("div"); + matchDiv.className = + "w-32 h-10 flex items-center justify-center bg-white text-center text-sm input-border"; + + matchDiv.textContent = ""; + nextRound.push(""); + + roundColumn.appendChild(matchDiv); + this.tournament_state[round].push(-1); + } + + bracketWrapper.appendChild(roundColumn); + currentRound = nextRound; + } + + document.getElementById("bracket")?.appendChild(document.createElement("hr")).classList.add("my-4", "mb-8", "w-64", "reverse-border"); + document.getElementById("bracket")?.appendChild(bracketWrapper); + const btn = document.getElementById("bracket")?.appendChild(document.createElement("button")); + if (!btn) return; + btn.classList.add("default-button", "w-full"); + btn.id = "tournament-play"; + btn.onclick = async () => { + document.getElementById("tournament-id")?.classList.add("hidden"); + let players: string[] = []; + for (let i of Array(initPlayerCount).keys()) { + players.push((document.getElementById(`playerName${i}`) as HTMLInputElement).value); + } + + while (tournament[0].length > 1) + { + this.updateBracketDisplay(tournament, players); + while(tournament[0].length > 0) + { + const p1 = tournament[0].shift() as number; + const p2 = tournament[0].shift() as number; + + document.getElementById("announcement-text").innerText = `${players[p1]} vs ${players[p2]}`; + document.getElementById("announcement")?.classList.remove("hidden"); + await this.waitForUserClick("tournament-continue"); + document.getElementById("announcement")?.classList.add("hidden"); + const result = await this.runGame(p1, p2, players); + document.getElementById("gameCanvas")?.remove(); + tournament[1].push(result); + } + tournament[0] = tournament[1]; + tournament[1] = []; + } + document.getElementById("winner-div")?.classList.remove("hidden"); + document.getElementById("winner-text").innerText = `${players[tournament[0][0]]} won the tournament !! ggs :D`; + }; + btn.innerText = "start tournament !!"; + + }; + + document.getElementById("bracket-generate")?.addEventListener("click", () => { + const input: HTMLInputElement = document.getElementById("playerNumber") as HTMLInputElement; + if (input.value == "") + return; + generateBracket(+input.value); + }); + } +} diff --git a/src/front/static/ts/views/drag.ts b/src/front/static/ts/views/drag.ts new file mode 100644 index 0000000..d1d7b32 --- /dev/null +++ b/src/front/static/ts/views/drag.ts @@ -0,0 +1,43 @@ +import { setOnekoOffset } from "../oneko.ts"; + +export function dragElement(el) { + var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; + if (document.getElementById(el.id + "-header")) { + // if present, the header is where you move the DIV from: + document.getElementById(el.id + "-header").onmousedown = dragMouseDown; + } else { + // otherwise, move the DIV from anywhere inside the DIV: + el.onmousedown = dragMouseDown; + } + + function dragMouseDown(e) { + e = e || window.event; + e.preventDefault(); + // get the mouse cursor position at startup: + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + // call a function whenever the cursor moves: + document.onmousemove = elementDrag; + } + + function elementDrag(e) { + e = e || window.event; + e.preventDefault(); + // calculate the new cursor position: + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + // set the element's new position: + el.style.top = (el.offsetTop - pos2) + "px"; + el.style.left = (el.offsetLeft - pos1) + "px"; + setOnekoOffset(); + } + + function closeDragElement() { + // stop moving when mouse button is released: + document.onmouseup = null; + document.onmousemove = null; + } +} diff --git a/src/front/style.css b/src/front/style.css deleted file mode 100644 index b5c61c9..0000000 --- a/src/front/style.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/src/utils/authUserCreate.js b/src/utils/authUserCreate.js index a1d6bba..e8a6771 100644 --- a/src/utils/authUserCreate.js +++ b/src/utils/authUserCreate.js @@ -10,10 +10,10 @@ export async function authUserCreate(username, fastify) { }; const cookie = fastify.jwt.sign({ user: "admin" }); - const url = process.env.USER_URL || "http://localhost:3002/" + const url = process.env.USER_URL || "http://localhost:3002" await axios.post( - url + "users/" + username, + url + "/users/" + username, payload, { headers: { diff --git a/tailwind.config.js b/tailwind.config.js index d2fdab4..53376f7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,13 @@ export default { - content: ['./src/front/**/*.{html,js}'], + content: ['./src/front/**/*.{html,js,ts,css}'], theme: { - extend: {}, + extend: { + fontFamily: { + jersey: ['"Jersey 10"', 'sans-serif'], + }, + }, + }, + }, }, plugins: [], }