Compare commits

..

No commits in common. "main" and "contrast" have entirely different histories.

184 changed files with 11752 additions and 12787 deletions

View file

@ -1,66 +0,0 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: [self-hosted, linux]
defaults:
run:
working-directory: ./leaky-ships
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- uses: pnpm/action-setup@v2
name: Install pnpm
with:
version: 8.7.4
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('./leaky-ships/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Add FA token
run: |
npm config set "@fortawesome:registry" "https://npm.fontawesome.com/"
npm config set "//npm.fontawesome.com/:_authToken" "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browsers
run: pnpm playwright install --with-deps
- name: 'Compiling page'
run: |
echo "${{ secrets.ENV_FILE }}" > .env
pnpm build
- name: Run Playwright tests
run: pnpm playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: leaky-ships/playwright-report/
retention-days: 30

View file

@ -1,6 +1,5 @@
# leaky-ships
Battleship web app made with SolidJS using solid-start.
Battleship web app with react frontend and ASP.NET Core backend
## Bluetooth

View file

@ -1,11 +1,6 @@
{
"parser": "@typescript-eslint/parser",
"env": {
"node": true
},
"plugins": ["solid"],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:solid/typescript"
]
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@next/next/no-img-element": "off"
}
}

View file

@ -1,35 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
#logs
/log
src/drizzle/migrations
dist
.vinxi
.output
.vercel
.netlify
netlify
# Environment
.env
.env*.local
# dependencies
/node_modules
/.pnp
.pnp.js
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# testing
/coverage
# Temp
gitignore
# next.js
/.next/
/out/
# System Files
# production
/build
# misc
.DS_Store
Thumbs.db
*.pem
# playwright
/test-results/
/playwright-report/
/playwright/.cache/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,5 @@
{
"semi": false,
"plugins": ["prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}

View file

@ -1,8 +1,8 @@
import { createSignal } from "solid-js"
import { useState } from "react"
function Bluetooth() {
const [startDisabled, setStartDisabled] = createSignal(true)
const [stopDisabled, setStopDisabled] = createSignal(true)
const [startDisabled, setStartDisabled] = useState(true)
const [stopDisabled, setStopDisabled] = useState(true)
const deviceName = "Chromecast Remote"
// ble UV Index
@ -48,7 +48,7 @@ function Bluetooth() {
if (!isWebBluetoothEnabled()) return
return getDeviceInfo()
.then(connectGatt)
.then(() => {
.then((_) => {
console.log("Reading UV Index...")
return gattCharacteristic.readValue()
})
@ -77,7 +77,7 @@ function Bluetooth() {
gattCharacteristic = characteristic
characteristic.addEventListener(
"characteristicvaluechanged",
handleChangedValue,
handleChangedValue
)
setStartDisabled(false)
@ -94,7 +94,7 @@ function Bluetooth() {
const now = new Date()
// Output the UV Index
console.log(
`> ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()} UV Index is ${value}`,
`> ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()} UV Index is ${value}`
)
// Output the Battery percentage
@ -108,7 +108,7 @@ function Bluetooth() {
if (!isWebBluetoothEnabled()) return
gattCharacteristic
.startNotifications()
.then(() => {
.then((_) => {
console.log("Start reading...")
setStartDisabled(true)
setStopDisabled(false)
@ -119,7 +119,7 @@ function Bluetooth() {
if (!isWebBluetoothEnabled()) return
gattCharacteristic
.stopNotifications()
.then(() => {
.then((_) => {
console.log("Stop reading...")
setStartDisabled(false)
setStopDisabled(true)
@ -135,31 +135,31 @@ function Bluetooth() {
return (
<div>
<button id="read" class="bluetooth" onClick={read}>
<button id="read" className="bluetooth" onClick={read}>
Connect with BLE device
</button>
<button
id="start"
class="bluetooth"
disabled={startDisabled()}
className="bluetooth"
disabled={startDisabled}
onClick={start}
>
Start
</button>
<button
id="stop"
class="bluetooth"
disabled={stopDisabled()}
className="bluetooth"
disabled={stopDisabled}
onClick={stop}
>
Stop
</button>
<p>
<span
class="App-link"
className="App-link"
onClick={() => {
navigator.clipboard.writeText(
"chrome://flags/#enable-experimental-web-platform-features",
"chrome://flags/#enable-experimental-web-platform-features"
)
}}
// target="_blank"
@ -169,10 +169,10 @@ function Bluetooth() {
Step 1
</span>{" "}
<span
class="App-link"
className="App-link"
onClick={() => {
navigator.clipboard.writeText(
"chrome://flags/#enable-web-bluetooth-new-permissions-backend",
"chrome://flags/#enable-web-bluetooth-new-permissions-backend"
)
}}
// target="_blank"

View file

@ -0,0 +1,15 @@
import React from "react"
function BurgerMenu() {
return (
<button className="absolute top-16 left-16 flex h-24 w-24 items-center justify-center rounded-lg bg-grayish">
<img
className="pixelart h-20 w-20"
src="/assets/burger-menu.png"
alt="Burger Menu"
/>
</button>
)
}
export default BurgerMenu

View file

@ -0,0 +1,65 @@
import { CSSProperties, Dispatch, SetStateAction } from "react"
import { borderCN, cornerCN, fieldIndex } from "../../lib/utils/helpers"
import { Position, MouseCursor } from "../../interfaces/frontend"
type TilesType = {
key: number
isGameTile: boolean
classNameString: string
x: number
y: number
}
function BorderTiles({
props: { count, settingTarget, setMouseCursor, setLastLeftTile },
}: {
props: {
count: number
settingTarget: (isGameTile: boolean, x: number, y: number) => void
setMouseCursor: Dispatch<SetStateAction<MouseCursor>>
setLastLeftTile: Dispatch<SetStateAction<Position>>
}
}) {
let tilesProperties: TilesType[] = []
for (let y = 0; y < count + 2; y++) {
for (let x = 0; x < count + 2; x++) {
const key = fieldIndex(count, x, y)
const cornerReslt = cornerCN(count, x, y)
const borderType = cornerReslt ? cornerReslt : borderCN(count, x, y)
const isGameTile = x > 0 && x < count + 1 && y > 0 && y < count + 1
const classNames = ["border-tile"]
if (borderType) classNames.push("edge", borderType)
if (isGameTile) classNames.push("game-tile")
const classNameString = classNames.join(" ")
tilesProperties.push({
key,
classNameString,
isGameTile,
x: x + 1,
y: y + 1,
})
}
}
return (
<>
{tilesProperties.map(({ key, classNameString, isGameTile, x, y }) => {
return (
<div
key={key}
className={classNameString}
style={{ "--x": x, "--y": y } as CSSProperties}
onClick={() => settingTarget(isGameTile, x, y)}
onMouseEnter={() =>
setMouseCursor({ x, y, shouldShow: isGameTile })
}
onMouseLeave={() => setLastLeftTile({ x, y })}
></div>
)
})}
</>
)
}
export default BorderTiles

View file

@ -0,0 +1,38 @@
import React, { Dispatch, SetStateAction } from "react"
import { Items, Target } from "../../interfaces/frontend"
import Item from "./Item"
function EventBar({
props: { setMode, setTarget },
}: {
props: {
setMode: Dispatch<SetStateAction<number>>
setTarget: Dispatch<SetStateAction<Target>>
}
}) {
const items: Items[] = [
{ icon: "burger-menu", text: "Menu" },
{ icon: "radar", text: "Radar scan", mode: 0, amount: 1 },
{ icon: "torpedo", text: "Fire torpedo", mode: 1, amount: 1 },
{ icon: "scope", text: "Fire missile", mode: 2 },
{ icon: "gear", text: "Settings" },
]
return (
<div className="event-bar">
{items.map((e, i) => (
<Item
key={i}
props={{
...e,
callback: () => {
if (e.mode !== undefined) setMode(e.mode)
setTarget((e) => ({ ...e, show: false }))
},
}}
/>
))}
</div>
)
}
export default EventBar

View file

@ -0,0 +1,13 @@
import Image from "next/image"
function FogImages() {
return (
<>
<Image className="fog left" src={`/fog/fog2.png`} alt={`fog1.png`} />
<Image className="fog right" src={`/fog/fog2.png`} alt={`fog1.png`} />
<Image className="fog middle" src={`/fog/fog4.png`} alt={`fog4.png`} />
</>
)
}
export default FogImages

View file

@ -0,0 +1,41 @@
import { CSSProperties } from "react"
// import Bluetooth from './Bluetooth'
import BorderTiles from "./BorderTiles"
import EventBar from "./EventBar"
// import FogImages from './FogImages'
import HitElems from "./HitElems"
import Labeling from "./Labeling"
import Ships from "./Ships"
import useGameEvent from "../../lib/hooks/useGameEvent"
import Targets from "./Targets"
function Gamefield() {
const count = 12
const { pointersProps, targetsProps, tilesProps, hits } = useGameEvent(count)
return (
<div id="gamefield">
{/* <Bluetooth /> */}
<div id="game-frame" style={{ "--i": count } as CSSProperties}>
{/* Bordes */}
<BorderTiles props={tilesProps} />
{/* Collumn lettes and row numbers */}
<Labeling count={count} />
{/* Ships */}
<Ships />
<HitElems hits={hits} />
{/* Fog images */}
{/* <FogImages /> */}
<Targets props={pointersProps} />
{/* <span id='dev-debug' style={{gridArea: '1 / 12 / 1 / 15', backgroundColor: 'red', zIndex: 3} as CSSProperties}>Debug</span> */}
</div>
<EventBar props={targetsProps} />
</div>
)
}
export default Gamefield

View file

@ -0,0 +1,39 @@
import { faCrosshairs } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { CSSProperties } from "react"
import classNames from "classnames"
import { faRadar } from "@fortawesome/pro-thin-svg-icons"
import { Target, TargetList } from "../../interfaces/frontend"
export interface PointerProps extends Target, TargetList {
imply: boolean
}
function GamefieldPointer({
props: { preview, x, y, show, type, edges, imply },
}: {
props: PointerProps
}) {
const isRadar = type === "radar"
const style = !(isRadar && !edges.filter((s) => s).length)
? { "--x": x, "--y": y }
: { "--x1": x - 1, "--x2": x + 2, "--y1": y - 1, "--y2": y + 2 }
return (
<div
className={classNames(
"hit-svg",
{ preview: preview },
"target",
type,
{ show: show },
...edges,
{ imply: imply }
)}
style={style as CSSProperties}
>
<FontAwesomeIcon icon={!isRadar ? faCrosshairs : faRadar} />
</div>
)
}
export default GamefieldPointer

View file

@ -0,0 +1,22 @@
import { faBurst, faXmark } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { CSSProperties } from "react"
import { Hit } from "../../interfaces/frontend"
function HitElems({ hits }: { hits: Hit[] }) {
return (
<>
{hits.map(({ hit, x, y }, i) => (
<div
key={i}
className="hit-svg"
style={{ "--x": x, "--y": y } as CSSProperties}
>
<FontAwesomeIcon icon={hit ? faBurst : faXmark} />
</div>
))}
</>
)
}
export default HitElems

View file

@ -0,0 +1,33 @@
import classNames from "classnames"
import React, { CSSProperties } from "react"
function Item({
props: { icon, text, amount, callback },
}: {
props: {
icon: string
text: string
amount?: number
callback: () => void
}
}) {
return (
<div className="item" onClick={callback}>
<div
className={classNames("container", { amount: amount })}
style={
amount
? ({
"--amount": JSON.stringify(amount.toString()),
} as CSSProperties)
: {}
}
>
<img src={`/assets/${icon}.png`} alt={`${icon}.png`} />
</div>
<span>{text}</span>
</div>
)
}
export default Item

View file

@ -1,10 +1,9 @@
import classNames from "classnames"
import { For } from "solid-js"
import { fieldIndex } from "~/lib/utils/helpers"
import { CSSProperties } from "react"
import { fieldIndex } from "../../lib/utils/helpers"
import { Field } from "../../interfaces/frontend"
import { count } from "./Gamefield"
function Labeling() {
function Labeling({ count }: { count: number }) {
let elems: (Field & {
orientation: string
})[] = []
@ -27,23 +26,24 @@ function Labeling() {
x: count + 2,
y: x + 2,
orientation: "right",
},
}
)
}
elems = elems.sort(
(a, b) => fieldIndex(count, a.x, a.y) - fieldIndex(count, b.x, b.y),
(a, b) => fieldIndex(count, a.x, a.y) - fieldIndex(count, b.x, b.y)
)
return (
<For each={elems}>
{(props) => (
<>
{elems.map(({ field, x, y, orientation }, i) => (
<span
class={classNames("label", props.orientation, props.field)}
style={{ "--x": props.x, "--y": props.y }}
key={i}
className={classNames("label", orientation, field)}
style={{ "--x": x, "--y": y } as CSSProperties}
>
{props.field}
{field}
</span>
)}
</For>
))}
</>
)
}

View file

@ -0,0 +1,34 @@
import classNames from "classnames"
import { CSSProperties } from "react"
function Ships() {
let shipIndexes = [
{ size: 2, index: null },
{ size: 3, index: 1 },
{ size: 3, index: 2 },
{ size: 3, index: 3 },
{ size: 4, index: 1 },
{ size: 4, index: 2 },
]
return (
<>
{shipIndexes.map(({ size, index }, i) => {
const filename = `/assets/ship_blue_${size}x${
index ? "_" + index : ""
}.gif`
return (
<div
key={i}
className={classNames("ship", "s" + size)}
style={{ "--x": i + 3 } as CSSProperties}
>
<img src={filename} alt={filename} />
</div>
)
})}
</>
)
}
export default Ships

View file

@ -0,0 +1,28 @@
import React from "react"
import { Target } from "../../interfaces/frontend"
import GamefieldPointer, { PointerProps } from "./GamefieldPointer"
function Targets({
props: { composeTargetTiles, target, targetPreview },
}: {
props: {
composeTargetTiles: (target: Target) => PointerProps[]
target: Target
targetPreview: Target
}
}) {
return (
<>
{[
...composeTargetTiles(target).map((props, i) => (
<GamefieldPointer key={"t" + i} props={props} />
)),
...composeTargetTiles(targetPreview).map((props, i) => (
<GamefieldPointer key={"p" + i} props={props} />
)),
]}
</>
)
}
export default Targets

View file

@ -0,0 +1,112 @@
import classNames from "classnames"
import { CSSProperties, useEffect, useMemo, useState } from "react"
function Grid() {
function floorClient(number: number) {
return Math.floor(number / 50)
}
const [columns, setColumns] = useState(0)
const [rows, setRows] = useState(0)
const [params, setParams] = useState({
columns,
rows,
quantity: columns * rows,
})
const [position, setPosition] = useState([0, 0])
const [active, setActve] = useState(false)
const [count, setCount] = useState(0)
useEffect(() => {
function handleResize() {
setColumns(floorClient(document.body.clientWidth))
setRows(floorClient(document.body.clientHeight))
}
handleResize()
window.addEventListener("resize", handleResize)
}, [])
useEffect(() => {
const timeout = setTimeout(() => {
setParams({ columns, rows, quantity: columns * rows })
}, 500)
return () => clearTimeout(timeout)
}, [columns, rows])
const createTiles = useMemo(() => {
const colors = [
"rgb(229, 57, 53)",
"rgb(253, 216, 53)",
"rgb(244, 81, 30)",
"rgb(76, 175, 80)",
"rgb(33, 150, 243)",
"rgb(156, 39, 176)",
]
function createTile(index: number) {
const x = index % params.columns
const y = Math.floor(index / params.columns)
const xDiff = (x - position[0]) / 20
const yDiff = (y - position[1]) / 20
const pos = Math.sqrt(xDiff * xDiff + yDiff * yDiff).toFixed(2)
function doEffect(posX: number, posY: number) {
if (active) return
setPosition([posX, posY])
setActve(true)
function xDiff(x: number) {
return (x - posX) / 20
}
function yDiff(y: number) {
return (y - posY) / 20
}
function pos(x: number, y: number) {
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
}
const diagonals = [
pos(0, 0),
pos(params.columns, 0),
pos(0, params.rows),
pos(params.columns, params.rows),
]
setTimeout(() => {
setActve(false)
setCount((e) => e + 1)
}, Math.max(...diagonals) * 1000 + 300)
}
return (
<div
key={index}
className={classNames("tile", { active: active })}
style={{ "--delay": pos + "s" } as CSSProperties}
onClick={() => doEffect(x, y)}
></div>
)
}
return (
<div
id="tiles"
style={
{
"--columns": params.columns,
"--rows": params.rows,
"--bg-color-1": colors[count % colors.length],
"--bg-color-2": colors[(count + 1) % colors.length],
} as CSSProperties
}
>
{Array.from(Array(params.quantity), (_tile, index) =>
createTile(index)
)}
</div>
)
}, [params, position, active, count])
return createTiles
}
export default Grid

View file

@ -0,0 +1,118 @@
import classNames from "classnames"
import { CSSProperties, useEffect, useMemo, useState } from "react"
function Grid2() {
function floorClient(number: number) {
return Math.floor(number / 50)
}
const [columns, setColumns] = useState(0)
const [rows, setRows] = useState(0)
const [params, setParams] = useState({
columns,
rows,
quantity: columns * rows,
})
const [position, setPosition] = useState([0, 0])
const [active, setActve] = useState(false)
const [action, setAction] = useState(false)
const [count, setCount] = useState(0)
useEffect(() => {
function handleResize() {
setColumns(floorClient(document.body.clientWidth))
setRows(floorClient(document.body.clientHeight))
}
handleResize()
window.addEventListener("resize", handleResize)
}, [])
useEffect(() => {
const timeout = setTimeout(() => {
setParams({ columns, rows, quantity: columns * rows })
}, 500)
return () => clearTimeout(timeout)
}, [columns, rows])
const createTiles = useMemo(() => {
const sentences = [
"Ethem ...",
"hat ...",
"lange ...",
"Hörner 🐂",
"Grüße von Mallorca 🌊 🦦 ☀️",
]
function createTile(index: number) {
const x = index % params.columns
const y = Math.floor(index / params.columns)
const xDiff = (x - position[0]) / 20
const yDiff = (y - position[1]) / 20
const pos = Math.sqrt(xDiff * xDiff + yDiff * yDiff).toFixed(2)
function doEffect(posX: number, posY: number) {
if (action) return
setPosition([posX, posY])
setActve((e) => !e)
setAction(true)
function xDiff(x: number) {
return (x - posX) / 20
}
function yDiff(y: number) {
return (y - posY) / 20
}
function pos(x: number, y: number) {
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
}
const diagonals = [
pos(0, 0),
pos(params.columns, 0),
pos(0, params.rows),
pos(params.columns, params.rows),
]
setTimeout(() => {
setAction(false)
if (active) setCount((e) => e + 1)
}, Math.max(...diagonals) * 1000 + 1000)
}
return (
<div
key={index}
className={classNames("tile", active ? "active" : "inactive")}
style={{ "--delay": pos + "s" } as CSSProperties}
onClick={() => doEffect(x, y)}
></div>
)
}
return (
<div
id="tiles"
style={
{
"--columns": params.columns,
"--rows": params.rows,
} as CSSProperties
}
>
<div className="center-div">
<h1
className={classNames("headline", !active ? "active" : "inactive")}
>
{sentences[count % sentences.length]}
</h1>
</div>
{Array.from(Array(params.quantity), (_tile, index) =>
createTile(index)
)}
</div>
)
}, [params, position, active, action, count])
return createTiles
}
export default Grid2

View file

@ -0,0 +1,27 @@
import { ReactNode } from "react"
function Icon({
src,
children,
onClick,
}: {
src: string
children: ReactNode
onClick?: () => void
}) {
return (
<button
className="mx-4 mt-4 flex flex-col items-center border-none"
onClick={onClick}
>
<img
className="pixelart mb-1 box-content w-16 rounded-xl bg-white p-1"
src={"/assets/" + src}
alt={src}
/>
<span className="font-semibold">{children}</span>
</button>
)
}
export default Icon

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from "react"
import Settings from "./SettingsFrame/Settings"
import Icon from "./Icon"
import Player from "./Player"
function LobbyFrame() {
const [settings, setSettings] = useState(false)
const [enemy, setEnemy] = useState(false)
const [dots, setDots] = useState(0)
useEffect(() => {
if (enemy) return
const interval = setInterval(() => setDots((e) => (e % 3) + 1), 1000)
return () => clearInterval(interval)
}, [])
return (
<div className="mx-32 flex flex-col self-stretch rounded-3xl bg-gray-400">
<div className="flex items-center justify-between border-b-2 border-slate-900">
<Icon src="speech_bubble.png">Chat</Icon>
<h1 className="font-farro text-5xl font-medium">
Game-PIN: <span className="underline">3169</span>
</h1>
<Icon src="gear.png" onClick={() => setSettings(true)}>
Settings
</Icon>
</div>
<div className="flex items-center justify-around">
<Player
src="player_blue.png"
text="Spieler 1 (Du)"
primary={true}
edit={true}
/>
<p
className="font-farro m-4 text-6xl font-semibold"
onClick={() => setEnemy((e) => !e)}
>
VS
</p>
{enemy ? (
<Player src="player_red.png" text="Spieler 2" />
) : (
<p className="font-farro m-12 w-64 text-center text-5xl font-medium">
Warte auf Spieler 2 {Array.from(Array(dots), () => ".").join("")}
</p>
)}
</div>
<div className="flex items-center justify-center border-t-2 border-slate-900">
<button className="font-farro m-8 rounded-xl bg-amber-400 px-12 py-4 text-5xl font-medium">
START
</button>
</div>
{settings ? <Settings closeSettings={() => setSettings(false)} /> : <></>}
</div>
)
}
export default LobbyFrame

View file

@ -0,0 +1,43 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faCaretDown } from "@fortawesome/sharp-solid-svg-icons"
import classNames from "classnames"
function Player({
src,
text,
primary,
edit,
}: {
src: string
text: string
primary?: boolean
edit?: boolean
}) {
return (
<div className="m-4 flex flex-col items-center">
<p
className={classNames(
"font-farro my-8 text-5xl",
primary ? "font-semibold" : "font-normal"
)}
>
{text}
</p>
<div className="relative m-8">
<img className="pixelart w-64" src={"/assets/" + src} alt={src} />
{edit ? (
<button className="absolute top-4 right-4 h-14 w-14 rounded-lg border-2 border-dashed border-warn bg-gray-800 bg-opacity-90">
<FontAwesomeIcon
className="h-full w-full text-warn"
icon={faCaretDown}
/>
</button>
) : (
<></>
)}
</div>
</div>
)
}
export default Player

View file

@ -0,0 +1,49 @@
import {
faToggleLargeOff,
faToggleLargeOn,
} from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import classNames from "classnames"
function Setting({
props: { key, state, onClick },
}: {
props: {
key: string
state: boolean
onClick: () => void
}
}) {
return (
<>
<label
htmlFor={key}
className="col-span-2 my-4 select-none text-8xl text-white drop-shadow-md"
>
{key}
</label>
<label
htmlFor={key}
className={state ? "rounded-full bg-gray-300 px-2 transition-all" : ""}
>
<FontAwesomeIcon
className={classNames(
"w-24 drop-shadow-md",
state ? "text-blue-500" : "text-gray-700"
)}
icon={state ? faToggleLargeOn : faToggleLargeOff}
/>
</label>
<input
className="bg-none"
checked={state}
type="checkbox"
id={key}
onChange={onClick}
hidden={true}
/>
</>
)
}
export default Setting

View file

@ -0,0 +1,55 @@
import { faXmark } from "@fortawesome/pro-solid-svg-icons"
import { faRotateLeft } from "@fortawesome/pro-regular-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { useState } from "react"
import Setting from "./Setting"
const settingOptionsInit: { [key: string]: boolean } = {
allowSpectators: false,
allowSpecials: false,
allowChat: false,
allowMarkDraw: false,
}
function Settings({ closeSettings }: { closeSettings: () => void }) {
const [settingOptions, setSettingOptions] = useState(settingOptionsInit)
return (
<div className="absolute inset-0 flex flex-col justify-center bg-black bg-opacity-40">
<div className="relative mx-16 flex flex-col rounded-3xl border-4 border-zinc-700 bg-zinc-400 p-8">
<h1 className="font-farro text-center text-6xl font-semibold text-white shadow-black drop-shadow-lg">
Settings
</h1>
<button
className="absolute top-6 right-6 h-14 w-14 "
onClick={closeSettings}
>
<FontAwesomeIcon
className="h-full w-full text-gray-700 drop-shadow-md"
icon={faXmark}
/>
</button>
<div className="relative m-8 rounded-xl bg-zinc-500 pt-16">
<button
className="absolute top-8 right-12 h-14 w-14"
onClick={() => setSettingOptions(settingOptionsInit)}
>
<FontAwesomeIcon
className="h-full w-full text-gray-700 drop-shadow-md"
icon={faRotateLeft}
/>
</button>
<div className="mr-32 grid grid-cols-3 items-center justify-items-start gap-4 p-8">
{Object.keys(settingOptions).map((key) => {
const state = settingOptions[key]
const onClick = () =>
setSettingOptions((e) => ({ ...e, [key]: !e[key] }))
return <Setting key={key} props={{ key, state, onClick }} />
})}
</div>
</div>
</div>
</div>
)
}
export default Settings

View file

@ -0,0 +1,17 @@
import React from "react"
function Logo() {
return (
<div className="flex flex-row bg-shield-gray bg-[url('/assets/shield.png')] bg-cover bg-no-repeat">
<div className="flex flex-col items-center justify-between">
<h1 className="font-checkpoint my-4 mx-32 border-y-4 border-slate-700 text-center text-6xl leading-tight tracking-widest">
Leaky
<br />
Ships
</h1>
</div>
</div>
)
}
export default Logo

View file

@ -0,0 +1,27 @@
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome"
import { ReactNode } from "react"
function OptionButton({
icon,
action,
children,
}: {
icon: FontAwesomeIconProps["icon"]
action?: () => void
children: ReactNode
}) {
return (
<button
className="flex w-full flex-row items-center justify-between rounded-lg border-2 border-black bg-voidDark py-4 pr-8 pl-16 text-4xl text-grayish first:mt-8 last:mt-8"
onClick={action}
>
<span className="mx-auto">{children}</span>
<FontAwesomeIcon className="ml-12 w-10 text-4xl" icon={icon} />
</button>
)
}
export default OptionButton

View file

@ -0,0 +1,38 @@
import { useEffect } from "react"
import { io } from "socket.io-client"
function SocketIO() {
useEffect(() => {
socketInitializer()
}, [])
const socketInitializer = async () => {
await fetch("/api/ws")
const socket = io()
socket.on("test2", (warst) => {
console.log("Test2:", warst, socket.id)
})
socket.on("connect", () => {
console.log(socket.connected) // true
setTimeout(() => {
socket.emit("test", "warst")
socket.emit("test", "tsra")
socket.emit("test", "1234")
// socket.disconnect()
}, 1000)
})
socket.on("test", () => {
console.log("Got test1234") // false
})
socket.on("disconnect", () => {
console.log(socket.connected) // false
})
}
return <div>SocketIO</div>
}
export default SocketIO

View file

@ -1,11 +0,0 @@
import "dotenv/config"
import type { Config } from "drizzle-kit"
export default {
schema: "./src/drizzle/schemas/Tables.ts",
out: "./src/drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL ?? "",
},
} satisfies Config

View file

@ -1,83 +0,0 @@
import { expect, test, type BrowserContext, type Page } from "@playwright/test"
let context: BrowserContext
let page: Page
test.describe.serial("Check Azure AD auth", () => {
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
page.route(/Regelwerk\.mp4/, (route) => route.abort())
})
test.afterAll(async () => {
await context.close()
})
test("Login process...", async () => {
await page.goto("/signin")
await page.locator("button#microsoft").click()
// Indicates email can be filled in
await page.waitForSelector("a#cantAccessAccount")
// Fill email input
await page.locator("input#i0116").fill(process.env.AUTH_EMAIL!)
// Click the "Next" button
await page.locator("input#idSIButton9").click()
// Indicates password can be filled in
await page.waitForSelector("a#idA_PWD_ForgotPassword")
// Fill password input
await page.locator("input#i0118").fill(process.env.AUTH_PW!)
// Click the "Sign in" button
await page.locator("input#idSIButton9").click()
// Click the "No" button
await page.locator("input#idBtn_Back").click()
await page.waitForSelector("#start")
})
test("Is logged in", async () => {
await page.goto("/signin")
await page.waitForSelector("button#signout")
await page.goto("/")
await page.waitForSelector("#start")
await page.evaluate(() => document.fonts.ready)
expect(await page.screenshot()).toMatchSnapshot("1.png", {
maxDiffPixelRatio: 0.02,
})
})
test("Is logged out", async () => {
await page.goto("/signout")
await page.locator("button#signout").click()
await page.waitForSelector("#start")
await page.goto(
"https://login.microsoftonline.com/common/oauth2/v2.0/logout",
)
await page.waitForSelector("div#loginHeader")
const emailLocator = page.locator(
`[data-test-id="${process.env.AUTH_EMAIL ?? ""}"]`,
)
if (await emailLocator.isVisible()) {
await emailLocator.click()
} else {
console.log(
"The email locator is not present on the page. Skipping this step.",
)
// Optionally, you can throw an error, fail the test, or take any other desired action here.
}
await page.waitForSelector("div#SignOutStatusMessage")
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

@ -1,76 +0,0 @@
import {
test,
type Browser,
type BrowserContext,
type Page,
} from "@playwright/test"
import { createHash, randomBytes } from "crypto"
import { and, desc, eq } from "drizzle-orm"
import db from "~/drizzle"
import { verificationTokens } from "~/drizzle/schemas/Tables"
const player1Email = (browser: Browser) =>
browser.browserType().name() + "-player-1@example.com"
let context: BrowserContext
let page: Page
test.describe.serial("Check Email auth", () => {
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
})
test.afterAll(async () => {
await context.close()
})
test("Email login process...", async ({ browser }) => {
await page.goto("/signin")
await page.locator("input#email").fill(player1Email(browser))
await page.locator("button#email-submit").click()
await page.waitForURL("/api/auth/verify-request?provider=email&type=email")
const token = randomBytes(32).toString("hex")
const hash = createHash("sha256")
// Prefer provider specific secret, but use default secret if none specified
.update(`${token}${process.env.AUTH_SECRET}`)
.digest("hex")
// Use drizzle to fetch the latest token for the email
const latestToken = await db.query.verificationTokens.findFirst({
where: eq(verificationTokens.identifier, player1Email(browser)),
orderBy: [desc(verificationTokens.expires)],
})
await db
.update(verificationTokens)
.set({ token: hash })
.where(
and(
eq(verificationTokens.identifier, player1Email(browser)),
eq(verificationTokens.token, latestToken?.token ?? ""),
),
)
const params = new URLSearchParams({
callbackUrl: process.env.AUTH_URL!,
token,
email: player1Email(browser),
})
await page.goto("/api/auth/callback/email?" + params)
})
test("Verify Logged in...", async () => {
await page.goto("/signin")
await page.waitForSelector("button#signout")
})
test("Logging out...", async () => {
await page.locator("button#signout").click()
await page.waitForSelector("#start")
})
})

3
leaky-ships/global.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module globalThis {
var prismaClient: PrismaClient
}

View file

@ -0,0 +1,16 @@
import type { Server as HTTPServer } from "http"
import type { NextApiResponse } from "next"
import type { Socket as NetSocket } from "net"
import type { Server as IOServer } from "socket.io"
interface SocketServer extends HTTPServer {
io?: IOServer | undefined
}
interface SocketWithIO extends NetSocket {
server: SocketServer
}
export interface NextApiResponseWithSocket extends NextApiResponse {
socket: SocketWithIO
}

View file

@ -0,0 +1,50 @@
export interface Position {
x: number
y: number
}
export interface Target extends Position {
preview: boolean
show: boolean
}
export interface MouseCursor extends Position {
shouldShow: boolean
}
export interface TargetList extends Position {
type: string
edges: string[]
}
export interface Mode {
pointerGrid: any[][]
type: string
}
export interface Items {
icon: string
text: string
mode?: number
amount?: number
}
export interface Field extends Position {
field: string
}
export interface Hit extends Position {
hit: boolean
}
interface fireMissile {
type: "fireMissile"
payload: {
x: number
y: number
hit: boolean
}
}
interface removeMissile {
type: "removeMissile"
payload: {
x: number
y: number
hit: boolean
}
}
export type HitDispatch = fireMissile | removeMissile

View file

@ -1,5 +0,0 @@
server_pid=$(lsof -i :3000 -t)
if [[ -n $server_pid ]]; then
echo "Killing server..." $server_pid
kill -9 $server_pid
fi

View file

@ -0,0 +1,23 @@
import { Player } from "@prisma/client"
import bcrypt from "bcrypt"
export default async function checkPasswordIsValid<T>(
payload: T & { player: Player; password: string }
) {
const { player, password } = payload
// Validate for correct password
return bcrypt.compare(password, player.passwordHash).then(async (result) => {
if (!result) {
return Promise.reject({
message: "Passwords do not match!",
statusCode: 401,
solved: true,
})
}
return {
...payload,
passwordIsValid: true,
}
})
}

View file

@ -0,0 +1,30 @@
import { Token } from "@prisma/client"
import jwt from "jsonwebtoken"
import jwtVerifyCatch from "../jwtVerifyCatch"
async function checkTokenIsValid<T>(
payload: T & { token: string; tokenType: Token["type"] }
) {
const { token, tokenType } = payload
// Verify the token and get the payload
let tokenData: string | jwt.JwtPayload
try {
tokenData = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET as string)
} catch (err: any) {
// Deal with the problem in more detail
return Promise.reject(jwtVerifyCatch(tokenType, err))
}
// Making sure the token data is not a string (because it should be an object)
if (typeof tokenData === "string") {
return Promise.reject({
message: tokenType + "-Token data was a string. Token: " + token,
statusCode: 401,
solved: false,
})
}
return { ...payload, tokenBody: token, tokenIsValid: true }
}
export default checkTokenIsValid

View file

@ -0,0 +1,28 @@
import prisma from "../../prisma"
async function createAnonymousDB<T>(payload: T) {
const player = await prisma.player.create({
data: {
anonymous: true,
},
})
// .catch((err: any) => {
// if (err.code === 11000) {
// return Promise.reject({
// message: `Duplicate key error while creating Player in DB!`,
// statusCode: 409,
// solved: true,
// type: 'warn'
// })
// } else {
// console.log(err)
// return Promise.reject({
// message: `Unknown error while creating Player in DB.`,
// solved: false
// })
// }
// })
return { ...payload, player }
}
export default createAnonymousDB

View file

@ -0,0 +1,38 @@
import bcrypt from "bcrypt"
import prisma from "../../prisma"
async function createPlayerDB<T>(
payload: T & { username: string; password: string }
) {
const { username, password } = payload
return await prisma.player
.create({
data: {
username,
passwordHash: await bcrypt.hash(password, 10),
anonymous: false,
},
})
.then((player) => {
return { ...payload, player }
})
.catch((err: any) => {
if (err.code === 11000) {
return Promise.reject({
message: `Duplicate key error while creating Player in DB!`,
statusCode: 409,
solved: true,
type: "warn",
})
} else {
console.log(err)
return Promise.reject({
message: `Unknown error while creating Player in DB.`,
solved: false,
})
}
})
}
export default createPlayerDB

View file

@ -0,0 +1,42 @@
import { Player, Token } from "@prisma/client"
import jwt from "jsonwebtoken"
import { v4 as uuidv4 } from "uuid"
import prisma from "../../prisma"
const tokenLifetime = {
REFRESH: 172800,
ACCESS: 15,
}
export default async function createTokenDB<T>(
payload: T & { player: Player; newTokenType: Token["type"] }
) {
const { player, newTokenType } = payload
// Sign a new access token
const newToken = jwt.sign(
{ uuid: uuidv4(), user: player.id },
process.env.ACCESS_TOKEN_SECRET as string,
{ expiresIn: tokenLifetime[newTokenType] }
)
// Save token to DB
const newTokenDB = await prisma.token.create({
data: {
token: newToken,
type: newTokenType,
expires: new Date(Date.now() + tokenLifetime[newTokenType] + "000"),
owner: {
connect: {
id: player.id,
},
},
},
})
return {
...payload,
newToken,
newTokenDB,
}
}

View file

@ -0,0 +1,26 @@
import { Token } from "@prisma/client"
import prisma from "../../prisma"
export default async function getPlayerByIdDB<T>(
payload: T & { tokenDB: Token }
) {
const { tokenDB } = payload
// Find Host in DB if it still exists (just to make sure)
const player = await prisma.player.findUnique({
where: {
id: tokenDB.ownerId,
},
})
if (!player) {
return Promise.reject({
message: "Player not found in DB!",
statusCode: 401,
solved: false,
})
}
return {
...payload,
player,
}
}

View file

@ -0,0 +1,32 @@
import prisma from "../../prisma"
export default async function getPlayerByNameDB<T>(
payload: T & { username: string }
) {
const { username } = payload
// Find Player in DB if it still exists (just to make sure)
const player = await Promise.any([
prisma.player.findUnique({
where: {
username: username,
},
}),
prisma.player.findUnique({
where: {
email: username,
},
}),
])
if (!player) {
return Promise.reject({
message: "Player not found in DB!",
statusCode: 401,
solved: false,
})
}
return {
...payload,
player,
}
}

View file

@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from "next"
import prisma from "../../prisma"
async function getTokenDB<T>(
payload: T & {
tokenBody: string
tokenIsValid: boolean
req: NextApiRequest
res: NextApiResponse<any>
}
) {
const { tokenBody } = payload
// Find refresh token in DB
const tokenDB = await prisma.token.findUnique({
where: {
token: tokenBody,
},
})
if (!tokenDB) {
return Promise.reject({
message: "Access-Token not found in DB!",
statusCode: 401,
solved: true,
type: "warn",
})
}
if (tokenDB.used) {
return Promise.reject({
message: "DBToken was already used!",
statusCode: 401,
solved: true,
})
}
await prisma.token.update({
where: {
token: tokenBody,
},
data: {
used: true,
},
})
// await logging('Old token has been invalidated.', ['debug'], req)
return {
...payload,
tokenDB,
}
}
export default getTokenDB

View file

@ -0,0 +1,19 @@
import { NextApiRequest } from "next"
async function getTokenFromBody<T>(payload: T & { req: NextApiRequest }) {
const { req } = payload
const token: string = req.body.token
// Checking for cookie presens, because it is necessary
if (!token) {
return Promise.reject({
message: "Unauthorized. No Access-Token.",
statusCode: 401,
solved: true,
})
}
return { ...payload, token }
}
export default getTokenFromBody

View file

@ -0,0 +1,20 @@
import { Token } from "@prisma/client"
import { NextApiRequest } from "next"
async function getTokenFromCookie<T>(payload: T & { req: NextApiRequest }) {
const { req } = payload
const token = req.cookies.token
// Checking for cookie presens, because it is necessary
if (!token) {
return Promise.reject({
message: "Unauthorized. No cookie.",
statusCode: 401,
solved: true,
})
}
return { ...payload, token, tokenType: "REFRESH" as Token["type"] }
}
export default getTokenFromCookie

View file

@ -0,0 +1,17 @@
import { Token } from "@prisma/client"
import { Logging } from "../logging"
export default async function loginCheck<T>(
payload: T & { loginCheck: boolean; tokenDB: Token; tokenType: "REFRESH" }
) {
const { loginCheck, tokenDB } = payload
// True login check response
if (loginCheck) {
return Promise.resolve({
message: "loginCheck " + loginCheck + " of " + tokenDB.id,
body: { loggedIn: true },
type: ["debug", "info.cyan"] as Logging[],
})
}
return payload
}

View file

@ -0,0 +1,12 @@
import { NextApiRequest, NextApiResponse } from "next"
import logging from "../logging"
export default function sendError<T>(
req: NextApiRequest,
res: NextApiResponse<T>,
err: any
) {
// If something went wrong, let the client know with status 500
res.status(err.statusCode ?? 500).end()
logging(err.message, [err.type ?? (err.solved ? "debug" : "error")], req)
}

View file

@ -0,0 +1,20 @@
import { NextApiRequest, NextApiResponse } from "next"
import logging, { Logging } from "../logging"
export interface Result<T> {
message: string
statusCode?: number
body?: T
type?: Logging[]
}
export default function sendResponse<T>(payload: {
req: NextApiRequest
res: NextApiResponse<T>
result: Result<T>
}) {
const { req, res, result } = payload
res.status(result.statusCode ?? 200)
result.body ? res.json(result.body) : res.end()
logging(result.message, result.type ?? ["debug"], req)
}

View file

@ -0,0 +1,36 @@
import { Token } from "@prisma/client"
export default async function jwtVerifyCatch(
tokenType: Token["type"],
err: Error
) {
switch (err.message) {
case "jwt expired":
return {
message: `JWT (${tokenType}) expired!`,
statusCode: 403,
solved: true,
type: "warn",
}
case "invalid signature":
return {
message: `Invalid JWT (${tokenType}) signature! Token: `,
statusCode: 401,
solved: true,
type: "error",
}
case "jwt must be provided":
return {
message: `No JWT (${tokenType}) given.`,
statusCode: 401,
solved: true,
type: "warn",
}
default:
console.log(err)
return { message: `Unknown error on 'JWT.verify()'.`, solved: false }
}
}

View file

@ -1,22 +1,13 @@
import { APIEvent } from "@solidjs/start/server/types"
import colors, { Color } from "colors"
import fs from "fs"
import colors, { Color } from "colors"
import { NextApiRequest } from "next"
import { IncomingMessage } from "http"
colors.enable()
const loggingTemplates: {
system: LoggingType
infoGreen: LoggingType
infoCyan: LoggingType
debug: LoggingType
post: LoggingType
warn: LoggingType
error: LoggingType
} = {
const loggingTemplates: { [key: string]: LoggingType } = {
system: ["SYSTEM", "green"],
infoGreen: ["INFO", "green"],
infoCyan: ["INFO", "cyan"],
"info.green": ["INFO", "green"],
"info.cyan": ["INFO", "cyan"],
debug: ["Debug", "grey"],
post: ["Post", "white"],
warn: ["WARN", "yellow"],
@ -31,7 +22,7 @@ let started: boolean = false
async function logStartup() {
await fs.promises.stat("log").catch(async () => {
await fs.promises.mkdir("log")
await logging(`Created 'log' Folder.`, ["infoCyan", "system"])
await logging(`Created 'log' Folder.`, ["info.cyan", "system"])
})
started = true
}
@ -39,7 +30,7 @@ async function logStartup() {
async function logging(
message: string,
types: Logging[],
request?: APIEvent["request"] | IncomingMessage,
req?: NextApiRequest | IncomingMessage
) {
if (!started) await logStartup()
const messages = { console: message, file: message }
@ -55,18 +46,11 @@ async function logging(
messages.console =
`[${new Date().toString().slice(0, 33)}] ` + messages.console
messages.file = `[${new Date().toString().slice(0, 33)}] ` + messages.file
if (request) {
const xForwardedFor =
typeof request.headers.get === "function"
? request.headers.get("x-forwarded-for")
: // @ts-expect-error Bad IncomingHttpHeaders Type
request.headers["x-forwarded-for"]
const ip = (xForwardedFor || "127.0.0.1, 192.168.178.1").split(",")
const route = request.url
messages.console = [ip[0].yellow, route?.green, messages.console].join(
" - ",
)
messages.file = [ip[0], route, messages.file].join(" - ")
if (req) {
const forwardedFor: any = req.headers["x-forwarded-for"]
const ip = (forwardedFor || "127.0.0.1, 192.168.178.1").split(",")
messages.console = ip[0].yellow + " - " + messages.console
messages.file = ip[0] + " - " + messages.file
}
await fs.promises.appendFile("log/log.txt", messages.file + "\n")
console.log(messages.console)

View file

@ -0,0 +1,29 @@
import { GetServerSidePropsContext, PreviewData } from "next"
import { ParsedUrlQuery } from "querystring"
import getTokenFromCookie from "../backend/components/getTokenFromCookie"
import checkTokenIsValid from "../backend/components/checkTokenIsValid"
import getTokenDB from "../backend/components/getTokenDB"
import getPlayerByIdDB from "../backend/components/getPlayerByIdDB"
import logging from "../backend/logging"
export default async function checkIsLoggedIn(
context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) {
const req: any = context.req
const res: any = context.res
const isLoggedIn = await getTokenFromCookie({ req, res })
.then(checkTokenIsValid)
.then(getTokenDB)
.then(getPlayerByIdDB)
.then(({ player }) => !!player)
.catch(() => false)
logging(
"loginCheck " + (isLoggedIn ? true : "-> loggedIn: " + false),
["debug", "info.cyan"],
req
)
return isLoggedIn
}

View file

@ -0,0 +1,7 @@
export default function getAccessToken(): Promise<string> {
return fetch("/api/auth", {
method: "GET",
})
.then((res) => res.json())
.then((res) => res.newAccessToken)
}

View file

@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"
import {
hitReducer,
initlialLastLeftTile,
initlialTarget,
initlialTargetPreview,
initlialMouseCursor,
} from "../utils/helpers"
import {
Hit,
Mode,
MouseCursor,
Target,
Position,
} from "../../interfaces/frontend"
import { PointerProps } from "../../components/Gamefield/GamefieldPointer"
const modes: Mode[] = [
{
pointerGrid: Array.from(Array(3), () => Array.from(Array(3))),
type: "radar",
},
{
pointerGrid: Array.from(Array(3), () => Array.from(Array(1))),
type: "htorpedo",
},
{
pointerGrid: Array.from(Array(1), () => Array.from(Array(3))),
type: "vtorpedo",
},
{ pointerGrid: [[{ x: 0, y: 0 }]], type: "missile" },
]
function useGameEvent(count: number) {
const [lastLeftTile, setLastLeftTile] =
useState<Position>(initlialLastLeftTile)
const [target, setTarget] = useState<Target>(initlialTarget)
const [eventReady, setEventReady] = useState(false)
const [appearOK, setAppearOK] = useState(false)
const [targetPreview, setTargetPreview] = useState<Target>(
initlialTargetPreview
)
const [mouseCursor, setMouseCursor] =
useState<MouseCursor>(initlialMouseCursor)
const [hits, DispatchHits] = useReducer(hitReducer, [] as Hit[])
const [mode, setMode] = useState(0)
const targetList = useCallback(
(target: Position) => {
const { pointerGrid, type } = modes[mode]
const xLength = pointerGrid.length
const yLength = pointerGrid[0].length
const { x: targetX, y: targetY } = target
return pointerGrid
.map((arr, i) => {
return arr.map((_, i2) => {
const relativeX = -Math.floor(xLength / 2) + i
const relativeY = -Math.floor(yLength / 2) + i2
const x = targetX + (relativeX ?? 0)
const y = targetY + (relativeY ?? 0)
return {
x,
y,
type,
edges: [
i === 0 ? "left" : "",
i === xLength - 1 ? "right" : "",
i2 === 0 ? "top" : "",
i2 === yLength - 1 ? "bottom" : "",
],
}
})
})
.reduce((prev, curr) => [...prev, ...curr], [])
},
[mode]
)
const isHit = useCallback(
(x: number, y: number) => {
return hits.filter((h) => h.x === x && h.y === y)
},
[hits]
)
const settingTarget = useCallback(
(isGameTile: boolean, x: number, y: number) => {
if (!isGameTile || isHit(x, y).length) return
setMouseCursor((e) => ({ ...e, shouldShow: false }))
setTarget((t) => {
if (t.x === x && t.y === y && t.show) {
DispatchHits({
type: "fireMissile",
payload: { hit: (x + y) % 2 !== 0, x, y },
})
return { preview: false, show: false, x, y }
} else {
const target = { preview: false, show: true, x, y }
const hasAnyBorder = targetList(target).filter(({ x, y }) =>
isBorder(x, y, count)
).length
if (hasAnyBorder) return t
return target
}
})
},
[count, isHit, targetList]
)
const isSet = useCallback(
(x: number, y: number) => {
return (
!!targetList(target).filter((field) => x === field.x && y === field.y)
.length && target.show
)
},
[target, targetList]
)
const composeTargetTiles = useCallback(
(target: Target): PointerProps[] => {
const { preview, show } = target
const result = targetList(target).map(({ x, y, type, edges }) => {
return {
preview,
x,
y,
show,
type,
edges,
imply: !!isHit(x, y).length || (!!isSet(x, y) && preview),
}
})
return result
},
[isHit, isSet, targetList]
)
// handle visibility and position change of targetPreview
useEffect(() => {
const { show, x, y } = targetPreview
// if mouse has moved too quickly and last event was entering and leaving the same field, it must have gone outside the grid
const hasLeft = x === lastLeftTile.x && y === lastLeftTile.y
const isSet = x === target.x && y === target.y && target.show
if (show && !appearOK) setTargetPreview((e) => ({ ...e, show: false }))
if (
!show &&
mouseCursor.shouldShow &&
eventReady &&
appearOK &&
!isHit(x, y).length &&
!isSet &&
!hasLeft
)
setTargetPreview((e) => ({ ...e, show: true }))
}, [
targetPreview,
mouseCursor.shouldShow,
isHit,
eventReady,
appearOK,
lastLeftTile,
target,
])
// enable targetPreview event again after 200 ms.
useEffect(() => {
setEventReady(false)
const previewTarget = { x: mouseCursor.x, y: mouseCursor.y }
const hasAnyBorder = targetList(previewTarget).filter(({ x, y }) =>
isBorder(x, y, count)
).length
if (targetPreview.show || !appearOK || hasAnyBorder) return
const autoTimeout = setTimeout(() => {
setTargetPreview((e) => ({ ...e, ...previewTarget }))
setEventReady(true)
setAppearOK(true)
}, 300)
// or abort if state has changed early
return () => {
clearTimeout(autoTimeout)
}
}, [
appearOK,
count,
mouseCursor.x,
mouseCursor.y,
targetList,
targetPreview.show,
])
// approve targetPreview new position after 200 mil. sec.
useEffect(() => {
// early return to start cooldown only when about to show up
const autoTimeout = setTimeout(
() => {
setAppearOK(!targetPreview.show)
},
targetPreview.show ? 500 : 300
)
// or abort if movement is repeated early
return () => {
clearTimeout(autoTimeout)
}
}, [targetPreview.show])
return {
tilesProps: { count, settingTarget, setMouseCursor, setLastLeftTile },
pointersProps: { composeTargetTiles, target, targetPreview },
targetsProps: { setMode, setTarget },
hits,
}
}
function isBorder(x: number, y: number, count: number) {
return x < 2 || x > count + 1 || y < 2 || y > count + 1
}
export default useGameEvent

15
leaky-ships/lib/prisma.ts Normal file
View file

@ -0,0 +1,15 @@
// lib/prisma.ts
import { PrismaClient } from "@prisma/client"
let prisma: PrismaClient
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient()
} else {
if (!global.prismaClient) {
global.prismaClient = new PrismaClient()
}
prisma = global.prismaClient
}
export default prisma

View file

@ -0,0 +1,51 @@
import { Hit, HitDispatch } from "../../interfaces/frontend"
export function borderCN(count: number, x: number, y: number) {
if (x === 0) return "left"
if (y === 0) return "top"
if (x === count + 1) return "right"
if (y === count + 1) return "bottom"
return ""
}
export function cornerCN(count: number, x: number, y: number) {
if (x === 0 && y === 0) return "left-top-corner"
if (x === count + 1 && y === 0) return "right-top-corner"
if (x === 0 && y === count + 1) return "left-bottom-corner"
if (x === count + 1 && y === count + 1) return "right-bottom-corner"
return ""
}
export function fieldIndex(count: number, x: number, y: number) {
return y * (count + 2) + x
}
export function hitReducer(formObject: Hit[], action: HitDispatch) {
switch (action.type) {
case "fireMissile": {
const result = [...formObject, action.payload]
return result
}
default:
return formObject
}
}
export const initlialLastLeftTile = {
x: 0,
y: 0,
}
export const initlialTarget = {
preview: false,
show: false,
x: 2,
y: 2,
}
export const initlialTargetPreview = {
preview: true,
show: false,
x: 2,
y: 2,
}
export const initlialMouseCursor = {
shouldShow: false,
x: 0,
y: 0,
}

View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

7114
leaky-ships/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,80 +1,54 @@
{
"name": "leaky-ships",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"lint": "eslint --fix \"**/*.{ts,tsx,js,jsx}\"",
"push": "drizzle-kit push:pg",
"test": "pnpm playwright test --ui",
"typecheck": "tsc --noEmit --checkJs false --skipLibCheck"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"type": "module",
"dependencies": {
"@auth/core": "^0.27.0",
"@auth/drizzle-adapter": "^0.7.0",
"@auth/solid-start": "^0.6.1",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/pro-duotone-svg-icons": "^6.5.1",
"@fortawesome/pro-light-svg-icons": "^6.5.1",
"@fortawesome/pro-regular-svg-icons": "^6.5.1",
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/pro-thin-svg-icons": "^6.5.1",
"@fortawesome/sharp-solid-svg-icons": "^6.5.1",
"@paralleldrive/cuid2": "^2.2.2",
"@solidjs/meta": "^0.29.3",
"@solidjs/router": "^0.12.4",
"@solidjs/start": "^0.5.9",
"classnames": "^2.5.1",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/pro-duotone-svg-icons": "^6.2.1",
"@fortawesome/pro-light-svg-icons": "^6.2.1",
"@fortawesome/pro-regular-svg-icons": "^6.2.1",
"@fortawesome/pro-solid-svg-icons": "^6.2.1",
"@fortawesome/pro-thin-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/sharp-solid-svg-icons": "^6.2.1",
"@next/font": "13.1.1",
"@prisma/client": "^4.9.0",
"bcrypt": "^5.1.0",
"classnames": "^2.3.2",
"colors": "^1.4.0",
"drizzle-orm": "^0.29.4",
"drizzle-zod": "^0.5.1",
"http-status": "^1.7.3",
"json-stable-stringify": "^1.1.1",
"lodash-es": "^4.17.21",
"nodemailer": "^6.9.10",
"object-hash": "^3.0.0",
"postgres": "^3.4.3",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4",
"solid-color": "^0.0.4",
"solid-js": "^1.8.15",
"tinycolor2": "^1.6.0",
"unique-names-generator": "^4.7.1",
"vinxi": "^0.3.3",
"zod": "3.22.4"
"cookies-next": "^2.1.1",
"eslint": "8.31.0",
"eslint-config-next": "13.1.1",
"jsonwebtoken": "^9.0.0",
"next": "13.1.1",
"prisma": "^4.9.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io": "^4.5.4",
"socket.io-client": "^4.5.4",
"typescript": "4.9.4",
"uuid": "^9.0.0"
},
"packageManager": "pnpm@8.7.4",
"devDependencies": {
"@playwright/test": "^1.41.2",
"@total-typescript/ts-reset": "^0.5.1",
"@types/json-stable-stringify": "^1.0.36",
"@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.14",
"@types/object-hash": "^3.0.6",
"@types/web-bluetooth": "^0.0.20",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"autoprefixer": "^10.4.17",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.20.14",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-solid": "^0.13.1",
"pg": "^8.11.3",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11",
"sass": "^1.71.1",
"solid-start-node": "^0.3.10",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.4"
},
"pnpm": {
"overrides": {
"@auth/core": "^0.13.0",
"solid-start": "^0.3.5"
}
"@types/bcrypt": "^5.0.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/node": "^18.11.18",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/uuid": "^9.0.0",
"@types/web-bluetooth": "^0.0.16",
"autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.6.0",
"postcss": "^8.4.21",
"prettier": "^2.8.3",
"prettier-plugin-tailwindcss": "^0.2.2",
"sass": "^1.57.1",
"tailwindcss": "^3.2.4"
}
}

View file

@ -0,0 +1,10 @@
import "../styles/App.scss"
import "../styles/grid.scss"
import "../styles/grid2.scss"
import "../styles/homepage.scss"
import "../styles/globals.css"
import type { AppProps } from "next/app"
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}

View file

@ -0,0 +1,13 @@
import { Html, Head, Main, NextScript } from "next/document"
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View file

@ -0,0 +1,57 @@
import { NextApiRequest, NextApiResponse } from "next"
import getTokenFromCookie from "../../lib/backend/components/getTokenFromCookie"
import checkTokenIsValid from "../../lib/backend/components/checkTokenIsValid"
import getTokenDB from "../../lib/backend/components/getTokenDB"
import getPlayerByIdDB from "../../lib/backend/components/getPlayerByIdDB"
import createTokenDB from "../../lib/backend/components/createTokenDB"
import sendResponse from "../../lib/backend/components/sendResponse"
import sendError from "../../lib/backend/components/sendError"
import { Logging } from "../../lib/backend/logging"
import { Token } from "@prisma/client"
interface Data {
token: string
}
export default async function auth(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
return getTokenFromCookie({
req,
res,
newTokenType: "ACCESS" as Token["type"],
})
.then(checkTokenIsValid)
.then(getTokenDB)
.then(getPlayerByIdDB)
.then(createTokenDB)
.then(authResponse<Data>)
.then(sendResponse<Data>)
.catch((err) => sendError(req, res, err))
}
async function authResponse<T>(payload: {
newToken: string
newTokenDB: Token
tokenDB: Token
req: NextApiRequest
res: NextApiResponse<T>
}) {
const { newToken, newTokenDB, tokenDB, req, res } = payload
// Successfull response
return {
req,
res,
result: {
message:
"Access-Token generated: " +
newTokenDB.id +
" with Refreshtoken-Token: " +
tokenDB.id,
body: { token: newToken },
type: ["debug", "info.cyan"] as Logging[],
},
}
}

View file

@ -0,0 +1,52 @@
import { NextApiRequest, NextApiResponse } from "next"
import getTokenFromBody from "../../lib/backend/components/getTokenFromBody"
import checkTokenIsValid from "../../lib/backend/components/checkTokenIsValid"
import getTokenDB from "../../lib/backend/components/getTokenDB"
import getPlayerByIdDB from "../../lib/backend/components/getPlayerByIdDB"
import sendResponse from "../../lib/backend/components/sendResponse"
import sendError from "../../lib/backend/components/sendError"
import { Logging } from "../../lib/backend/logging"
import { Game, Player, Token } from "@prisma/client"
interface Data {
games: Game[]
}
export default async function data(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
return getTokenFromBody({ req, res, tokenType: "ACCESS" as Token["type"] })
.then(checkTokenIsValid)
.then(getTokenDB)
.then(getPlayerByIdDB)
.then(dataResponse<Data>)
.then(sendResponse<Data>)
.catch((err) => sendError(req, res, err))
}
async function dataResponse<T>(payload: {
player: Player
tokenDB: Token
// games: Game[],
req: NextApiRequest
res: NextApiResponse<T>
}) {
const { player, tokenDB, req, res } = payload
const games: any = {}
// Successfull response
return {
req,
res,
result: {
message:
"Requested data of user: " +
player.id +
" with Access-Token: " +
tokenDB.id,
body: { games },
type: ["debug", "info.cyan"] as Logging[],
},
}
}

View file

@ -0,0 +1,98 @@
import { NextApiRequest, NextApiResponse } from "next"
import logging, { Logging } from "../../lib/backend/logging"
import getPlayerByNameDB from "../../lib/backend/components/getPlayerByNameDB"
import checkPasswordIsValid from "../../lib/backend/components/checkPasswordIsValid"
import createTokenDB from "../../lib/backend/components/createTokenDB"
import sendResponse from "../../lib/backend/components/sendResponse"
import sendError from "../../lib/backend/components/sendError"
import { setCookie } from "cookies-next"
import { Player, Token } from "@prisma/client"
import prisma from "../../lib/prisma"
interface Data {
loggedIn: boolean
}
export default async function login(
req: NextApiRequest,
res: NextApiResponse<any>
) {
const { username, password } = req.body
return preCheck({
req,
res,
username,
password,
newTokenType: "REFRESH" as Token["type"],
})
.then(getPlayerByNameDB)
.then(checkPasswordIsValid)
.then(createTokenDB)
.then(loginResponse<Data>)
.then(sendResponse<Data>)
.catch((err) => sendError(req, res, err))
}
async function preCheck<T>(
payload: T & {
req: NextApiRequest
res: NextApiResponse<T>
}
) {
const { req } = payload
const oldRefreshToken = req.cookies.token
// Check for old cookie, if unused invalidate it
const oldDBToken = await prisma.token.findUnique({
where: {
token: oldRefreshToken,
},
})
if (oldDBToken?.used) {
await prisma.token.update({
where: {
token: oldRefreshToken,
},
data: {
used: true,
},
})
await logging("Old token has been invalidated.", ["debug"], req)
}
return { ...payload, noCookiePresent: true }
}
async function loginResponse<T>(payload: {
player: Player
passwordIsValid: boolean
refreshToken: string
refreshTokenDB: Token
req: NextApiRequest
res: NextApiResponse<T>
}) {
const { player, refreshToken, refreshTokenDB, req, res } = payload
// Set login cookie
setCookie("token", refreshToken, {
req,
res,
maxAge: 172800000,
httpOnly: true,
sameSite: true,
secure: true,
})
// Successfull response
return {
req,
res,
result: {
message:
"User " +
player.id +
" logged in and generated Refresh-Token: " +
refreshTokenDB.id,
body: { loggedIn: true },
type: ["debug", "info.cyan"] as Logging[],
},
}
}

View file

@ -0,0 +1,47 @@
import { NextApiRequest, NextApiResponse } from "next"
import checkTokenIsValid from "../../lib/backend/components/checkTokenIsValid"
import sendResponse from "../../lib/backend/components/sendResponse"
import sendError from "../../lib/backend/components/sendError"
import { deleteCookie } from "cookies-next"
import { Token } from "@prisma/client"
import getTokenDB from "../../lib/backend/components/getTokenDB"
import getTokenFromCookie from "../../lib/backend/components/getTokenFromCookie"
import logging, { Logging } from "../../lib/backend/logging"
interface Data {
loggedOut: boolean
}
export default async function logout(
req: NextApiRequest,
res: NextApiResponse<any>
) {
return getTokenFromCookie({ req, res })
.then(checkTokenIsValid)
.then(getTokenDB)
.then(logoutResponse<Data>)
.then(sendResponse<Data>)
.catch((err) => sendError(req, res, err))
}
async function logoutResponse<T>(payload: {
tokenDB: Token
req: NextApiRequest
res: NextApiResponse<T>
}) {
const { tokenDB, req, res } = payload
// Set login cookie
deleteCookie("token", { req, res })
// Successfull response
return {
req,
res,
result: {
message: "User of Token " + tokenDB.id + " logged out.",
body: { loggedOut: true },
type: ["debug", "info.cyan"] as Logging[],
},
}
}

View file

@ -0,0 +1,41 @@
import { NextApiRequest, NextApiResponse } from "next"
import createPlayerDB from "../../lib/backend/components/createPlayerDB"
import sendError from "../../lib/backend/components/sendError"
import sendResponse from "../../lib/backend/components/sendResponse"
import { Logging } from "../../lib/backend/logging"
import { Player } from "@prisma/client"
interface Data {
registered: boolean
}
export default async function register(
req: NextApiRequest,
res: NextApiResponse<any>
) {
const { username, password } = req.body
return createPlayerDB({ req, res, username, password })
.then(registerResponse<Data>)
.then(sendResponse<Data>)
.catch((err) => sendError(req, res, err))
}
async function registerResponse<T>(payload: {
player: Player
req: NextApiRequest
res: NextApiResponse<T>
}) {
const { player, req, res } = payload
// Successfull response
return {
req,
res,
result: {
message: "Player created : " + player.id,
statusCode: 201,
body: { registered: true },
type: ["debug", "info.cyan"] as Logging[],
},
}
}

View file

@ -0,0 +1,32 @@
import type { NextApiRequest } from "next"
import { Server } from "socket.io"
import { NextApiResponseWithSocket } from "../../interfaces/NextApiSocket"
const SocketHandler = (req: NextApiRequest, res: NextApiResponseWithSocket) => {
if (res.socket.server.io) {
console.log("Socket is already running " + req.url)
} else {
console.log("Socket is initializing " + req.url)
const io = new Server(res.socket.server)
res.socket.server.io = io
io.on("connection", (socket) => {
socket.on("input-change", (msg) => {
socket.broadcast.emit("update-input", msg)
})
// console.log(socket.id)
// console.log(socket)
// ...
socket.on("test", (payload) => {
console.log("Got test:", payload)
// ...
})
socket.emit("test2", "lol")
})
}
res.end()
}
export default SocketHandler

View file

@ -0,0 +1,22 @@
import Head from "next/head"
import Gamefield from "../../components/Gamefield/Gamefield"
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="App">
<header className="App-header">
<Gamefield />
</header>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,22 @@
import Head from "next/head"
import Grid from "../../components/Grid"
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="App">
<header className="App-header">
<Grid />
</header>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,22 @@
import Head from "next/head"
import Grid2 from "../../components/Grid2"
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="App">
<header className="App-header">
<Grid2 />
</header>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,47 @@
import { faPlus, faUserPlus } from "@fortawesome/pro-solid-svg-icons"
import { faEye, faLeftLong } from "@fortawesome/pro-regular-svg-icons"
import { faCirclePlay } from "@fortawesome/pro-thin-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { useState } from "react"
import Logo from "../../components/Logo"
import BurgerMenu from "../../components/BurgerMenu"
import OptionButton from "../../components/OptionButton"
export default function Home() {
const [heWantsToPlay, setHeWantsToPlay] = useState<boolean | null>(false)
return (
<div className="h-full bg-theme">
<BurgerMenu />
<div className="mx-auto flex h-full max-w-screen-md flex-col items-center justify-around">
<Logo />
{!heWantsToPlay ? (
<>
<div className="transition-color flex h-full max-h-80 w-full max-w-screen-sm items-center justify-center rounded-xl border-4 border-black bg-[#2227] duration-200">
<FontAwesomeIcon className="text-8xl" icon={faCirclePlay} />
</div>
<button
className="font-farro transition-color rounded-2xl border-b-8 border-orange-400 bg-warn px-24 pt-10 pb-8 text-5xl font-bold duration-100 active:border-t-8 active:border-b-0"
onClick={() => setTimeout(() => setHeWantsToPlay(true), 200)}
>
START
</button>
</>
) : (
<div className="gap flex w-full flex-col items-center rounded-xl border-4 border-black bg-grayish p-12 shadow-lg">
<button
className="mt-[-1.5rem] w-40 self-start rounded-lg border-2 border-black bg-voidDark px-2 text-5xl text-grayish"
onClick={() => setHeWantsToPlay(false)}
>
<FontAwesomeIcon icon={faLeftLong} />
</button>
<div className="flex flex-col items-center gap-12">
<OptionButton icon={faPlus}>Raum erstellen</OptionButton>
<OptionButton icon={faUserPlus}>Raum beitreten</OptionButton>
<OptionButton icon={faEye}>Zuschauen</OptionButton>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,20 @@
import Head from "next/head"
import Logo from "../../components/Logo"
import LobbyFrame from "../../components/Lobby/LobbyFrame"
import BurgerMenu from "../../components/BurgerMenu"
export default function Home() {
return (
<div id="box">
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<BurgerMenu />
<Logo />
<LobbyFrame />
</div>
)
}

View file

@ -0,0 +1,65 @@
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import React, { useState } from "react"
function login() {
const [loading, setLoading] = useState(false)
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-900 bg-[url('/images/wallpaper.jpg')] bg-cover bg-center bg-no-repeat">
<div className="rounded-xl bg-gray-800 bg-opacity-50 px-16 py-10 shadow-lg backdrop-blur-md max-sm:px-8">
<div className="text-white">
<div className="mb-8 flex flex-col items-center">
<img
className="rounded-full shadow-lg"
src="/logo512.png"
width="150"
alt="Avatar"
/>
<h1 className="mb-2 text-2xl">Leaky Ships</h1>
<span className="text-gray-300">
{loading ? "Logging in..." : "Enter Login Details"}
</span>
</div>
{loading ? (
<FontAwesomeIcon
className="my-16 mx-24 animate-spin text-6xl"
icon={faSpinnerThird}
onClick={() => setLoading(false)}
/>
) : (
<form action="#">
<div className="mb-4 text-lg">
<input
className="rounded-3xl border-none bg-blue-400 bg-opacity-50 px-6 py-2 text-center text-inherit placeholder-slate-200 shadow-lg outline-none backdrop-blur-md"
type="text"
name="name"
placeholder="Username or email"
/>
</div>
<div className="mb-4 text-lg">
<input
className="rounded-3xl border-none bg-blue-400 bg-opacity-50 px-6 py-2 text-center text-inherit placeholder-slate-200 shadow-lg outline-none backdrop-blur-md"
type="Password"
name="name"
placeholder="Password"
/>
</div>
<div className="mt-8 flex justify-center text-lg text-black">
<button
type="submit"
className="rounded-3xl bg-blue-400 bg-opacity-50 px-10 py-2 text-white shadow-xl backdrop-blur-md transition-colors duration-300 hover:bg-blue-600"
onClick={() => setLoading(true)}
>
Login
</button>
</div>
</form>
)}
</div>
</div>
</div>
)
}
export default login

View file

@ -0,0 +1,38 @@
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { useRouter } from "next/router"
import React, { useEffect } from "react"
function logout() {
const router = useRouter()
useEffect(() => {
const timeout = setTimeout(() => router.push("/dev"), 2000)
return () => clearTimeout(timeout)
}, [])
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-900 bg-[url('/images/wallpaper.jpg')] bg-cover bg-center bg-no-repeat">
<div className="rounded-xl bg-gray-800 bg-opacity-50 px-16 py-10 shadow-lg backdrop-blur-md max-sm:px-8">
<div className="text-white">
<div className="mb-8 flex flex-col items-center">
<img
className="rounded-full shadow-lg"
src="/logo512.png"
width="150"
alt="Avatar"
/>
<h1 className="mb-2 text-2xl">Leaky Ships</h1>
<span className="text-gray-300">Logging out...</span>
</div>
<FontAwesomeIcon
className="my-16 mx-24 animate-spin text-6xl"
icon={faSpinnerThird}
/>
</div>
</div>
</div>
)
}
export default logout

View file

@ -0,0 +1,39 @@
import { ChangeEventHandler, useEffect, useState } from "react"
import { io } from "socket.io-client"
let socket: ReturnType<typeof io>
const Home = () => {
const [input, setInput] = useState("")
useEffect(() => {
socketInitializer()
}, [])
const socketInitializer = async () => {
await fetch("/api/ws")
socket = io()
socket.on("connect", () => {
console.log("connected")
})
socket.on("update-input", (msg) => {
setInput(msg)
})
}
const onChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setInput(e.target.value)
socket.emit("input-change", e.target.value)
}
return (
<input
placeholder="Type something"
value={input}
onChange={onChangeHandler}
/>
)
}
export default Home

View file

@ -0,0 +1,15 @@
import SocketIO from "../../components/SocketIO"
export default function Home() {
return (
<>
<main>
<div className="App">
<header className="App-header">
<SocketIO />
</header>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,62 @@
import Head from "next/head"
import Link from "next/link"
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<p>
<Link href="/dev/gamefield" target="_blank">
Gamefield
</Link>
</p>
<p>
<Link href="/dev" target="_blank">
Homepage
</Link>
</p>
<p>
<Link href="/dev/Lobby" target="_blank">
Lobby
</Link>
</p>
<p>
<Link href="/dev/login" target="_blank">
Login
</Link>
</p>
<p>
<Link href="/dev/logout" target="_blank">
Logout
</Link>
</p>
<p>
<Link href="/dev/grid" target="_blank">
Grid Effect
</Link>
</p>
<p>
<Link href="/dev/grid2" target="_blank">
Grid Effect with Content
</Link>
</p>
<p>
<Link href="/dev/socket" target="_blank">
Socket
</Link>
</p>
<p>
<Link href="/dev/socketio" target="_blank">
SocketIO
</Link>
</p>
</main>
</>
)
}

View file

@ -1,78 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
import dotenv from "dotenv"
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
dotenv.config()
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.AUTH_URL!,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm start",
url: process.env.AUTH_URL,
reuseExistingServer: !process.env.CI,
},
})

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
module.exports = {
semi: false,
plugins: [
"prettier-plugin-organize-imports",
"prettier-plugin-tailwindcss", // MUST come last
],
}

View file

@ -1,168 +1,78 @@
generator zod {
provider = "zod-prisma-types"
}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgres"
provider = "mongodb"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String?
access_token String?
expires_at Int?
ext_expires_in Int?
token_type String?
scope String?
id_token String?
session_state String?
oauth_token_secret String?
oauth_token String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
model Player {
id String @id @default(auto()) @map("_id") @db.ObjectId
uuid String @unique @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
anonymous Boolean @default(true)
username String @unique @default("")
email String @unique @default("")
passwordHash String @unique @default("")
games Game[] @relation(fields: [gameIds], references: [id])
gameIds String[] @db.ObjectId
tokens Token[]
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
enum TokenType {
REFRESH
ACCESS
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
games User_Game[]
accounts Account[]
sessions Session[]
@@map("users")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
enum GameState {
lobby
starting
running
ended
aborted
model Token {
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now())
expires DateTime @updatedAt
owner Player @relation(fields: [ownerId], references: [id])
ownerId String @db.ObjectId
token String @unique
type TokenType
used Boolean @default(false)
}
model Game {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
state GameState @default(lobby)
allowSpectators Boolean @default(true)
allowSpecials Boolean @default(true)
allowChat Boolean @default(true)
allowMarkDraw Boolean @default(true)
gamePin Gamepin?
users User_Game[]
}
model Gamepin {
id String @id @default(cuid())
createdAt DateTime @default(now())
pin String @unique
gameId String @unique
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
}
enum Orientation {
h
v
}
model Ship {
id String @id @default(cuid())
size Int
variant Int
x Int
y Int
orientation Orientation
user_GameId String
User_Game User_Game @relation(fields: [user_GameId], references: [id], onDelete: Cascade)
}
model Hit {
id String @id @default(cuid())
x Int
y Int
hit Boolean
user_GameId String
User_Game User_Game @relation(fields: [user_GameId], references: [id], onDelete: Cascade)
}
model User_Game {
id String @id @default(cuid())
createdAt DateTime @default(now())
gameId String
userId String
index Int
moves Move[]
ships Ship[]
hits Hit[]
chats Chat[]
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([gameId, index])
@@unique([gameId, userId])
}
enum MoveType {
missile
vtorpedo
htorpedo
radar
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
players Player[] @relation(fields: [playerIds], references: [id])
playerIds String[] @db.ObjectId
running Boolean @default(true)
Move Move[]
Gamepin Gamepin[]
Chat Chat[]
}
model Move {
id String @id @default(cuid())
createdAt DateTime @default(now())
index Int
type MoveType
x Int
y Int
orientation Orientation
user_game_id String
user_game User_Game @relation(fields: [user_game_id], references: [id], onDelete: Cascade)
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
index Int
game Game @relation(fields: [gameId], references: [id])
gameId String @db.ObjectId
}
model Gamepin {
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
pin Int @unique
game Game @relation(fields: [gameId], references: [id])
gameId String @db.ObjectId
}
model Chat {
id String @id @default(cuid())
createdAt DateTime @default(now())
message String?
event String?
user_game_id String
user_game User_Game @relation(fields: [user_game_id], references: [id], onDelete: Cascade)
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
message String @default("")
event String @default("")
game Game @relation(fields: [gameId], references: [id])
gameId String @db.ObjectId
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

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