Compare commits
11 Commits
d1200eea74
...
AddGames
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e72f0cc9a | |||
| 6eaf1d6b9f | |||
| ce19237fd2 | |||
| 06d75f9d29 | |||
| dc195aecc3 | |||
| a591fe4157 | |||
| 9d0017a1b6 | |||
| 3f62ec6251 | |||
| 28cf5edc6a | |||
| 1e975f56b6 | |||
| 3c1a277b37 |
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# flyctl launch added from .gitignore
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
**/.pnp.js
|
||||||
|
**/.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# production
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
**/npm-debug.log*
|
||||||
|
**/yarn-debug.log*
|
||||||
|
**/yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
**/.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
**/.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
**/next-env.d.ts
|
||||||
|
fly.toml
|
||||||
@@ -2,35 +2,50 @@ name: Build and deploy
|
|||||||
run-name: Build and deploy
|
run-name: Build and deploy
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# branches:
|
branches:
|
||||||
# - main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Checkout
|
||||||
name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
github-server-url: 'https://gitea.home.joemonk.co.uk'
|
github-server-url: 'https://gitea.home.joemonk.co.uk'
|
||||||
-
|
|
||||||
name: Set up docker
|
- name: Set up docker
|
||||||
run: 'curl -fsSL https://get.docker.com | sh'
|
run: 'curl -fsSL https://get.docker.com | sh'
|
||||||
-
|
|
||||||
name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
|
||||||
name: Login to private registry
|
- name: Login to gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}'
|
registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}'
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
-
|
|
||||||
name: Build and push
|
- name: Login to flyio registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: 'registry.fly.io'
|
||||||
|
username: x
|
||||||
|
password: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: 'gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest'
|
tags: |
|
||||||
|
gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest
|
||||||
|
gitea.home.joemonk.co.uk/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
registry.fly.io/joemonk:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
|
||||||
|
- run: flyctl deploy --remote-only -i registry.fly.io/joemonk:${{ gitea.sha }}
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
@@ -15,7 +15,7 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ RUN npm run build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
@@ -39,12 +39,13 @@ RUN chown nextjs:nodejs .next
|
|||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/db.sql ./db.sql
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
# server.js is created by next build from the standalone output
|
||||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||||
|
|||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './src/db/schema',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: `${process.cwd()}/db.sql`,
|
||||||
|
},
|
||||||
|
});
|
||||||
28
fly.toml
Normal file
28
fly.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# fly.toml app configuration file generated for joemonk on 2024-11-13T18:49:43Z
|
||||||
|
#
|
||||||
|
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||||
|
#
|
||||||
|
|
||||||
|
app = 'joemonk'
|
||||||
|
primary_region = 'lhr'
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 3000
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = 'stop'
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 1
|
||||||
|
processes = ["app"]
|
||||||
|
[[http_service.checks]]
|
||||||
|
grace_period = "30s"
|
||||||
|
interval = "120s"
|
||||||
|
method = "GET"
|
||||||
|
timeout = "5s"
|
||||||
|
path = "/api/status"
|
||||||
|
protocol = "http"
|
||||||
|
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
memory = '1gb'
|
||||||
|
cpu_kind = 'shared'
|
||||||
|
cpus = 1
|
||||||
@@ -6,16 +6,19 @@ const nextConfig = {
|
|||||||
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
||||||
experimental: {
|
experimental: {
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
ppr: true,
|
ppr: "incremental",
|
||||||
|
turbo: {
|
||||||
|
|
||||||
|
}
|
||||||
},
|
},
|
||||||
serverExternalPackages: ["typeorm"],
|
serverExternalPackages: ["typeorm", "better-sqlite3"],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "fly.storage.tigris.dev"
|
hostname: "fly.storage.tigris.dev",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
9479
package-lock.json
generated
9479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
80
package.json
80
package.json
@@ -3,49 +3,67 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:analyse": "ANALYZE=true npm run build",
|
"build:analyse": "ANALYZE=true npm run build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "next lint -- --fix"
|
"lint:fix": "next lint --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.663.0",
|
"@aws-sdk/client-s3": "^3.750.0",
|
||||||
"@heroicons/react": "^2.1.5",
|
"@dimforge/rapier2d-compat": "^0.14.0",
|
||||||
"@mdx-js/loader": "^3.0.1",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@mdx-js/react": "^3.0.1",
|
"@mdx-js/loader": "^3.1.0",
|
||||||
"@next/bundle-analyzer": "^14.2.13",
|
"@mdx-js/react": "^3.1.0",
|
||||||
"@next/mdx": "^14.2.13",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@next/mdx": "^15.1.7",
|
||||||
|
"@pixi-essentials/object-pool": "^1.0.1",
|
||||||
|
"@pixi/events": "^7.4.2",
|
||||||
|
"@tailwindcss/postcss": "^4.0.8",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/react-query": "^5.66.9",
|
||||||
|
"@tanstack/react-virtual": "^3.13.0",
|
||||||
|
"@trpc/client": "^11.0.0-rc.802",
|
||||||
|
"@trpc/react-query": "^11.0.0-rc.802",
|
||||||
|
"@trpc/server": "^11.0.0-rc.802",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^22.6.1",
|
"@types/node": "^22.13.5",
|
||||||
"@types/react": "^18.3.9",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
"@typescript-eslint/eslint-plugin": "^8.14.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "^0.0.0-experimental-6067d4e-20240924",
|
"babel-plugin-react-compiler": "beta",
|
||||||
"better-sqlite3": "^9.6.0",
|
"better-sqlite3": "^11.8.1",
|
||||||
"eslint": "^9.11.1",
|
"client-only": "^0.0.1",
|
||||||
"eslint-config-next": "^15.0.0-rc.0",
|
"drizzle-kit": "^0.30.4",
|
||||||
"exif-reader": "^2.0.1",
|
"drizzle-orm": "^0.39.3",
|
||||||
"framer-motion": "^11.5.6",
|
"eslint": "^9.21.0",
|
||||||
"glob": "^11.0.0",
|
"eslint-config-next": "^15.1.7",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"exif-reader": "^2.0.2",
|
||||||
|
"framer-motion": "^12.4.7",
|
||||||
|
"glob": "^11.0.1",
|
||||||
"million": "^3.1.11",
|
"million": "^3.1.11",
|
||||||
"next": "^15.0.0-rc.0",
|
"next": "15.2.0-canary.69",
|
||||||
"next-auth": "^5.0.0-beta",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"postcss": "^8.4.47",
|
"pixi-filters": "^6.1.0",
|
||||||
|
"pixi-viewport": "^6.0.3",
|
||||||
|
"pixi.js": "^8.8.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
"radash": "^12.1.0",
|
"radash": "^12.1.0",
|
||||||
"react": "^19.0.0-rc-04bd67a4-20240924",
|
"react": "19.0.0",
|
||||||
"react-dom": "^19.0.0-rc-04bd67a4-20240924",
|
"react-dom": "19.0.0",
|
||||||
"react-zoom-pan-pinch": "^3.6.1",
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
"superjson": "^2.2.2",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwind-scrollbar": "^4.0.0",
|
||||||
"typeorm": "^0.3.20",
|
"tailwindcss": "^4.0.8",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.7.3",
|
||||||
"yet-another-react-lightbox": "^3.21.6"
|
"yet-another-react-lightbox": "^3.21.7",
|
||||||
|
"zod": "^3.24.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: ["@tailwindcss/postcss"],
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { signIn } from "@/lib/auth"
|
import { signIn } from "@/lib/auth";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
export default function Auth(props: {
|
export default function Auth(props: {
|
||||||
searchParams: { callbackUrl: string | undefined }
|
searchParams: Promise<{ callbackUrl: string | undefined }>
|
||||||
}) {
|
}): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="w-40 mx-auto"
|
className="w-40 mx-auto"
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server"
|
"use server";
|
||||||
await signIn("authelia", {
|
await signIn("authelia", {
|
||||||
redirectTo: props.searchParams?.callbackUrl ?? "",
|
redirectTo: (await props.searchParams)?.callbackUrl ?? "",
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
className={`rounded-lg dark:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-transparent`}
|
className={`rounded-lg dark:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-transparent`}
|
||||||
>
|
>
|
||||||
<span>Sign in with Authelia</span>
|
<span>Sign in with Authelia</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
9
src/app/(root)/games/map/page.tsx
Normal file
9
src/app/(root)/games/map/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Wrapper from "./wrapper";
|
||||||
|
|
||||||
|
export default function Game(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Wrapper/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(root)/games/map/wrapper.tsx
Normal file
16
src/app/(root)/games/map/wrapper.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const MapGen = dynamic<Record<string, never>>(() => import('../../../../games/games/MapGen/MapGenWrapper').then((module) => module.default), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Wrapper(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<MapGen/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/app/(root)/games/player/page.tsx
Normal file
9
src/app/(root)/games/player/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Wrapper from "./wrapper";
|
||||||
|
|
||||||
|
export default function Game(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Wrapper/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(root)/games/player/wrapper.tsx
Normal file
16
src/app/(root)/games/player/wrapper.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const TestGame = dynamic<Record<string, never>>(() => import('../../../../games/games/TestGame/TestGameWrapper').then((module) => module.default), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Wrapper(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<TestGame/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
|
|
||||||
import NavBar from '@/components/navbar';
|
import NavBar from '@/components/navbar';
|
||||||
import Footer from '@/components/footer';
|
import Footer from '@/components/footer';
|
||||||
import LogIn from "@/components/auth/login";
|
|
||||||
|
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Lightbox from "@/components/lightbox";
|
import FilteredLightbox from "@/components/lightbox";
|
||||||
import { type GetPhotos } from "@/app/api/photos/route";
|
import { trpc } from "@/trpc/server";
|
||||||
|
import { TRPCProvider } from "@/trpc/client";
|
||||||
async function getImageData(): Promise<GetPhotos> {
|
|
||||||
const res = await fetch(`http://localhost:3000/api/photos`, { next: { revalidate: false, tags: ['photos'] } });
|
|
||||||
return res.json() as Promise<GetPhotos>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Photos(): Promise<React.JSX.Element> {
|
export default async function Photos(): Promise<React.JSX.Element> {
|
||||||
const {data: imageData} = await getImageData();
|
const { data: images } = await trpc.photos.list();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Lightbox imageData={imageData.images}>
|
<div className="mx-auto">
|
||||||
{imageData.images.map((image) => (
|
<TRPCProvider>
|
||||||
<Image
|
<FilteredLightbox imageData={images}>
|
||||||
key={image.src}
|
{images.map((image) => (
|
||||||
alt={image.src}
|
<Image
|
||||||
src={image.src}
|
key={image.src}
|
||||||
className="object-contain h-60 w-80"
|
alt={image.src}
|
||||||
sizes="100vw"
|
src={image.src}
|
||||||
loading="lazy"
|
className="object-contain h-60 w-80"
|
||||||
width={image.width}
|
sizes="100vw"
|
||||||
height={image.height}
|
loading="lazy"
|
||||||
blurDataURL={image.blur}
|
width={image.width}
|
||||||
placeholder="blur"
|
height={image.height}
|
||||||
/>
|
blurDataURL={image.blur}
|
||||||
))}
|
placeholder="blur"
|
||||||
</Lightbox>
|
/>
|
||||||
|
))}
|
||||||
|
</FilteredLightbox>
|
||||||
|
</TRPCProvider>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import { glob } from "glob";
|
import { glob } from "glob";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic, { LoaderComponent } from "next/dynamic";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export const dynamicParams = false;
|
export const dynamicParams = false;
|
||||||
|
|
||||||
export async function generateStaticParams(): Promise<{slug: string[]}[]> {
|
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
|
||||||
const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, {
|
const posts = await glob(
|
||||||
nodir: true,
|
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||||
});
|
{
|
||||||
|
nodir: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const slugs = posts.map((post) => ({
|
const slugs = posts.map((post) => ({
|
||||||
slug: [post.split('/').at(-1)!.slice(0, -4)]
|
slug: [post.split("/").at(-1)!.slice(0, -4)],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return slugs;
|
return slugs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Post({params}: {params: { slug: string[] }}): Promise<React.JSX.Element> {
|
export default async function Post({
|
||||||
const mdxFile = await import(`../../../../markdown/posts/[...slug]/${params.slug.join('/')}.mdx`)
|
params,
|
||||||
const Post = dynamic(async () => mdxFile);
|
}: {
|
||||||
return (
|
params: Promise<{ slug: string[] }>;
|
||||||
<Post/>
|
}): Promise<React.JSX.Element> {
|
||||||
);
|
const mdxFile = await import(
|
||||||
|
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
|
||||||
|
) as LoaderComponent<unknown>;
|
||||||
|
const Post = dynamic(() => mdxFile);
|
||||||
|
return <Post />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,42 +4,43 @@ import { unstable_cache } from "next/cache";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type postDetails = {
|
type postDetails = {
|
||||||
link: string,
|
link: string;
|
||||||
metadata: {
|
metadata: {
|
||||||
title: string,
|
title: string;
|
||||||
date: string,
|
date: string;
|
||||||
coverImage: string,
|
coverImage: string;
|
||||||
blurb: string,
|
blurb: string;
|
||||||
shortBlurb: string,
|
shortBlurb: string;
|
||||||
tags: string[]
|
tags: string[];
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function loadPostDetails(): Promise<postDetails[]> {
|
async function loadPostDetails(): Promise<postDetails[]> {
|
||||||
const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, {
|
const posts = await glob(
|
||||||
nodir: true,
|
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||||
});
|
{
|
||||||
|
nodir: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const loadPostData = posts.map(async (post) => {
|
const loadPostData = posts.map(async (post) => {
|
||||||
const slug = [post.split('/').at(-1)!.slice(0, -4)]
|
const slug = [post.split("/").at(-1)!.slice(0, -4)];
|
||||||
const mdxFile = await import(`../../../../src/markdown/posts/[...slug]/${slug.join('/')}.mdx`)
|
const mdxFile = await import(
|
||||||
|
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
|
||||||
|
) as postDetails;
|
||||||
return {
|
return {
|
||||||
|
link: getCurrentUrl() + "/posts/" + slug.join("/"),
|
||||||
metadata: mdxFile.metadata,
|
metadata: mdxFile.metadata,
|
||||||
link: getCurrentUrl() + '/posts/' + slug.join('/')
|
};
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const postData = await Promise.all(loadPostData);
|
const postData = await Promise.all(loadPostData);
|
||||||
return postData;
|
return postData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPosts = unstable_cache(
|
const getPosts = unstable_cache(loadPostDetails, ["posts"], {
|
||||||
loadPostDetails,
|
revalidate: false,
|
||||||
['posts'],
|
});
|
||||||
{
|
|
||||||
revalidate: false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default async function Posts(): Promise<React.JSX.Element> {
|
export default async function Posts(): Promise<React.JSX.Element> {
|
||||||
const postDetails = await getPosts();
|
const postDetails = await getPosts();
|
||||||
@@ -56,17 +57,17 @@ export default async function Posts(): Promise<React.JSX.Element> {
|
|||||||
{post.metadata.tags.map((tag) => {
|
{post.metadata.tags.map((tag) => {
|
||||||
return (
|
return (
|
||||||
<div key={`${post.link}_${tag}`}>
|
<div key={`${post.link}_${tag}`}>
|
||||||
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span>
|
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>{post.metadata.blurb}</p>
|
||||||
{post.metadata.blurb}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { NextRequest } from "next/server";
|
|||||||
import { handlers } from "@/lib/auth";
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
|
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
|
||||||
const proto = req.headers.get('x-forwarded-proto')
|
const proto = req.headers.get('x-forwarded-proto');
|
||||||
const host = req.headers.get('x-forwarded-host')
|
const host = req.headers.get('x-forwarded-host');
|
||||||
if (!proto || !host) {
|
if (!proto || !host) {
|
||||||
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.")
|
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
|
||||||
return req
|
return req;
|
||||||
}
|
}
|
||||||
const envOrigin = `${proto}://${host}`
|
const envOrigin = `${proto}://${host}`;
|
||||||
const { href, origin } = req.nextUrl
|
const { href, origin } = req.nextUrl;
|
||||||
return new NextRequest(href.replace(origin, envOrigin), req)
|
return new NextRequest(href.replace(origin, envOrigin), req);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const GET = (req: NextRequest) => {
|
export const GET = (req: NextRequest): Promise<Response> => {
|
||||||
return handlers.GET(reqWithTrustedOrigin(req))
|
return handlers.GET(reqWithTrustedOrigin(req));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const POST = (req: NextRequest) => {
|
export const POST = (req: NextRequest): Promise<Response> => {
|
||||||
return handlers.POST(reqWithTrustedOrigin(req))
|
return handlers.POST(reqWithTrustedOrigin(req));
|
||||||
}
|
};
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import exifReader from "exif-reader";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { diff, sift } from "radash";
|
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
import PhotoDataSource from "@/data-source";
|
|
||||||
import { Photo } from "@/entity/photo";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
|
|
||||||
export type GetPhotosUpdate = {
|
|
||||||
status: number,
|
|
||||||
s3Photos: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET = auth(async function GET(req): Promise<Response> {
|
|
||||||
if (!req.auth) {
|
|
||||||
return NextResponse.json({ message: "Not authenticated" }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataSource = await PhotoDataSource.dataSource;
|
|
||||||
const photoRepository = dataSource.getRepository(Photo);
|
|
||||||
const currentSources = (await photoRepository.find({
|
|
||||||
select: {
|
|
||||||
src: true
|
|
||||||
}
|
|
||||||
})).map((photo) => photo.src);
|
|
||||||
|
|
||||||
const s3Client = new S3Client();
|
|
||||||
|
|
||||||
const listObjCmd = new ListObjectsV2Command({
|
|
||||||
Bucket: "joemonk-photos"
|
|
||||||
});
|
|
||||||
|
|
||||||
const s3Res = await s3Client.send(listObjCmd);
|
|
||||||
|
|
||||||
if (!s3Res.Contents) {
|
|
||||||
return NextResponse.json({ status: 500 })
|
|
||||||
}
|
|
||||||
const s3Photos = sift(s3Res.Contents.map((obj) => {
|
|
||||||
if (!obj.Key?.endsWith('/')) {
|
|
||||||
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
const newPhotos = diff(s3Photos, currentSources);
|
|
||||||
|
|
||||||
const imageData = newPhotos.map(async (fileName: string) => {
|
|
||||||
const getImageCmd = new GetObjectCommand({
|
|
||||||
Bucket: "joemonk-photos",
|
|
||||||
Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "")
|
|
||||||
})
|
|
||||||
const imgRes = await s3Client.send(getImageCmd);
|
|
||||||
const image = await imgRes.Body?.transformToByteArray();
|
|
||||||
|
|
||||||
const { width, height, exif } = await sharp(image).metadata();
|
|
||||||
const blur = await sharp(image)
|
|
||||||
.resize({ width: 12, height: 12, fit: 'inside' })
|
|
||||||
.toBuffer();
|
|
||||||
const exifData = exif ? exifReader(exif) : undefined;
|
|
||||||
|
|
||||||
const photo = new Photo();
|
|
||||||
photo.src = fileName;
|
|
||||||
photo.width = width ?? 10;
|
|
||||||
photo.height = height ?? 10;
|
|
||||||
photo.blur = `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`;
|
|
||||||
photo.camera = exifData?.Image?.Model ?? null;
|
|
||||||
|
|
||||||
photo.exposureBiasValue = exifData?.Photo?.ExposureBiasValue ?? null;
|
|
||||||
photo.fNumber = exifData?.Photo?.FNumber ?? null;
|
|
||||||
photo.isoSpeedRatings = exifData?.Photo?.ISOSpeedRatings ?? null;
|
|
||||||
photo.focalLength = exifData?.Photo?.FocalLength ?? null;
|
|
||||||
photo.dateTimeOriginal = exifData?.Photo?.DateTimeOriginal ?? null;
|
|
||||||
photo.lensModel = exifData?.Photo?.LensModel ?? null;
|
|
||||||
|
|
||||||
return photo;
|
|
||||||
});
|
|
||||||
|
|
||||||
const images = await Promise.all(imageData);
|
|
||||||
|
|
||||||
await photoRepository.save(images);
|
|
||||||
|
|
||||||
return NextResponse.json<GetPhotosUpdate>({ status: 200, s3Photos: newPhotos });
|
|
||||||
});
|
|
||||||
11
src/app/api/trpc/[trpc]/route.ts
Normal file
11
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||||
|
import { createTRPCContext } from '@/trpc/init';
|
||||||
|
import { appRouter } from '@/trpc/routers/_app';
|
||||||
|
const handler = (req: Request): Promise<Response> =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: '/api/trpc',
|
||||||
|
req,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: createTRPCContext,
|
||||||
|
});
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
.text-balance {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ export default async function LogIn(): Promise<React.JSX.Element | undefined> {
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server"
|
"use server";
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
await signOut({
|
await signOut({
|
||||||
redirectTo: `${getCurrentUrl()}/`
|
redirectTo: `${getCurrentUrl()}/`
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
await signIn("authelia")
|
await signIn("authelia");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,9 +15,22 @@ import "yet-another-react-lightbox/styles.css";
|
|||||||
import "yet-another-react-lightbox/plugins/thumbnails.css";
|
import "yet-another-react-lightbox/plugins/thumbnails.css";
|
||||||
import "yet-another-react-lightbox/plugins/captions.css";
|
import "yet-another-react-lightbox/plugins/captions.css";
|
||||||
|
|
||||||
import { type ImageData } from "@/app/api/photos/route";
|
import type { RouterOutput } from "@/trpc/routers/_app";
|
||||||
|
import { trpc } from "@/trpc/client";
|
||||||
|
|
||||||
function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: ImageData, offset: number, rect: {width: number, height: number}, unoptimized: boolean}): React.JSX.Element {
|
type ImageData = RouterOutput["photos"]["list"]["data"][number];
|
||||||
|
|
||||||
|
function NextJsImage({
|
||||||
|
slide,
|
||||||
|
offset,
|
||||||
|
rect,
|
||||||
|
unoptimized = false,
|
||||||
|
}: {
|
||||||
|
slide: ImageData;
|
||||||
|
offset: number;
|
||||||
|
rect: { width: number; height: number };
|
||||||
|
unoptimized: boolean;
|
||||||
|
}): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
on: { click },
|
on: { click },
|
||||||
carousel: { imageFit },
|
carousel: { imageFit },
|
||||||
@@ -29,13 +42,13 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image
|
|||||||
|
|
||||||
const width = !cover
|
const width = !cover
|
||||||
? Math.round(
|
? Math.round(
|
||||||
Math.min(rect.width, (rect.height / slide.height) * slide.width),
|
Math.min(rect.width, (rect.height / slide.height) * slide.width)
|
||||||
)
|
)
|
||||||
: rect.width;
|
: rect.width;
|
||||||
|
|
||||||
const height = !cover
|
const height = !cover
|
||||||
? Math.round(
|
? Math.round(
|
||||||
Math.min(rect.height, (rect.width / slide.width) * slide.height),
|
Math.min(rect.height, (rect.width / slide.width) * slide.height)
|
||||||
)
|
)
|
||||||
: rect.height;
|
: rect.height;
|
||||||
|
|
||||||
@@ -56,38 +69,141 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image
|
|||||||
}}
|
}}
|
||||||
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
|
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
|
||||||
onClick={
|
onClick={
|
||||||
offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined
|
offset === 0
|
||||||
|
? (): void => click?.({ index: currentIndex })
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyLightbox({imageData, children}: {imageData: ImageData[], children: React.JSX.Element[]}): React.JSX.Element {
|
export function Lightbox({
|
||||||
|
imageData,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
imageData: ImageData[];
|
||||||
|
children: React.JSX.Element[];
|
||||||
|
}): React.JSX.Element {
|
||||||
const [active, setActive] = useState<number | null>(null);
|
const [active, setActive] = useState<number | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto">
|
<div className="mx-auto">
|
||||||
<div className="flex flex-row flex-wrap justify-center">
|
<div className="flex flex-row flex-wrap justify-center">
|
||||||
{children.map((image, index) => (
|
{children.map((image, index) => {
|
||||||
<button key={`lightbox_img_${index}`} onClick={(() => {
|
return (
|
||||||
setActive(index);
|
<button
|
||||||
})}>
|
key={`lightbox_img_${index}`}
|
||||||
<div className="relative">
|
onClick={() => {
|
||||||
{image}
|
setActive(index);
|
||||||
</div>
|
}}
|
||||||
</button>
|
>
|
||||||
))}
|
<div className="relative">{image}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<YARL
|
<YARL
|
||||||
open={typeof active === 'number'}
|
open={typeof active === "number"}
|
||||||
close={() => setActive(null)}
|
close={() => setActive(null)}
|
||||||
index={active ?? undefined}
|
index={active ?? undefined}
|
||||||
slides={imageData}
|
slides={imageData}
|
||||||
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
|
render={{
|
||||||
render={{ slide: (args) => NextJsImage({...args, unoptimized: true }), thumbnail: NextJsImage }}
|
// @ts-expect-error - Todo
|
||||||
|
slide: (args) => NextJsImage({ ...args, unoptimized: true }),
|
||||||
|
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
|
||||||
|
thumbnail: NextJsImage,
|
||||||
|
}}
|
||||||
plugins={[Thumbnails, Zoom, Captions]}
|
plugins={[Thumbnails, Zoom, Captions]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormElements extends HTMLFormControlsCollection {
|
||||||
|
src: HTMLInputElement;
|
||||||
|
}
|
||||||
|
interface UsernameFormElement extends HTMLFormElement {
|
||||||
|
readonly elements: FormElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilteredLightbox(props: {
|
||||||
|
imageData: ImageData[];
|
||||||
|
children: React.JSX.Element[];
|
||||||
|
}): React.JSX.Element {
|
||||||
|
//const [imageData, setImageData] = useState(props.imageData);
|
||||||
|
const [imageData] = useState(props.imageData);
|
||||||
|
const photoQuery = trpc.photos.list.useInfiniteQuery(
|
||||||
|
{
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
data: props.imageData,
|
||||||
|
next: props.imageData.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageParams: [0],
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshQuery = trpc.photos.update.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent<UsernameFormElement>): void {
|
||||||
|
event.preventDefault();
|
||||||
|
// const imageData = props.imageData;
|
||||||
|
// setImageData(
|
||||||
|
// imageData.filter(
|
||||||
|
// (data) => data.src === event.currentTarget.elements.src.value
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
void photoQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = photoQuery.data.pages
|
||||||
|
.flatMap((data) => data.data)
|
||||||
|
.map((data) => (
|
||||||
|
<Image
|
||||||
|
key={data.src}
|
||||||
|
alt={data.src}
|
||||||
|
src={data.src}
|
||||||
|
className="object-contain h-60 w-80"
|
||||||
|
sizes="100vw"
|
||||||
|
loading="lazy"
|
||||||
|
width={data.width}
|
||||||
|
height={data.height}
|
||||||
|
blurDataURL={data.blur}
|
||||||
|
placeholder="blur"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.filter((data) => !!data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="src">Src:</label>
|
||||||
|
<input id="src" type="text" />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void refreshQuery.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"}
|
||||||
|
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"}
|
||||||
|
<Lightbox imageData={imageData}>{...children}</Lightbox>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
'use client';
|
"use client";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { HomeModernIcon, Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
import {
|
||||||
import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion";
|
HomeModernIcon,
|
||||||
import { usePathname } from 'next/navigation';
|
Bars3Icon,
|
||||||
import ThemeSwitcher from './theme-switcher';
|
XMarkIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
LazyMotion,
|
||||||
|
domAnimation,
|
||||||
|
} from "framer-motion";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import ThemeSwitcher from "./theme-switcher";
|
||||||
|
|
||||||
type NavBarClientProps = {
|
type NavBarClientProps = {
|
||||||
LogIn: React.JSX.Element,
|
LogIn: React.JSX.Element;
|
||||||
navigation: {
|
navigation: {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
}[]
|
}[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NavBarClient({LogIn, navigation}: NavBarClientProps): React.JSX.Element {
|
export default function NavBarClient({
|
||||||
|
LogIn,
|
||||||
|
navigation,
|
||||||
|
}: NavBarClientProps): React.JSX.Element {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const activeNavigation = useMemo((): typeof navigation => {
|
const activeNavigation = useMemo((): typeof navigation => {
|
||||||
const nav = structuredClone(navigation);
|
const nav = structuredClone(navigation);
|
||||||
|
|
||||||
const current = nav.find((nav) => nav.href === pathname);
|
const current = nav.find((nav) => nav.href === pathname);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.current = true;
|
current.current = true;
|
||||||
}
|
}
|
||||||
return nav;
|
return nav;
|
||||||
}, [pathname]);
|
}, [pathname, navigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple">
|
<nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple">
|
||||||
@@ -35,64 +47,70 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re
|
|||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
<div className="relative flex h-16 items-center justify-between">
|
<div className="relative flex h-16 items-center justify-between">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button className='sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1' onClick={() => setOpen(!open)}>
|
<button
|
||||||
|
className="sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
{open ? (
|
{open ? (
|
||||||
<XMarkIcon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/>
|
<XMarkIcon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" />
|
||||||
) : (
|
) : (
|
||||||
<Bars3Icon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/>
|
<Bars3Icon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link className='hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors' href='/'>
|
<Link
|
||||||
<HomeModernIcon className='dark:stroke-dracula-cyan rounded-sm h-8 w-auto'/>
|
className="hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<HomeModernIcon className="dark:stroke-dracula-cyan rounded-sm h-8 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className='space-x-5 hidden sm:flex ml-10'>
|
<div className="space-x-5 hidden sm:flex ml-10">
|
||||||
{activeNavigation.map((item) => (
|
{activeNavigation.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${
|
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${
|
||||||
item.current ? 'dark:border-b-dracula-pink' : ''
|
item.current ? "dark:border-b-dracula-pink" : ""
|
||||||
}`}
|
}`}
|
||||||
aria-current={item.current ? 'page' : undefined}
|
aria-current={item.current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-x-4 flex'>
|
<div className="space-x-4 flex">
|
||||||
<ThemeSwitcher/>
|
<ThemeSwitcher />
|
||||||
{LogIn}
|
{LogIn}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{ open ? (
|
{open ? (
|
||||||
<m.div
|
<motion.div
|
||||||
initial={{ height: 0 }}
|
initial={{ height: 0 }}
|
||||||
animate={{ height: "auto" }}
|
animate={{ height: "auto" }}
|
||||||
transition={{ duration: 0.15, ease: 'linear' }}
|
transition={{ duration: 0.15, ease: "linear" }}
|
||||||
exit={{ height: 0 }}
|
exit={{ height: 0 }}
|
||||||
className='sm:hidden overflow-hidden'
|
className="sm:hidden overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className='flex flex-col space-y-1 py-1'>
|
<div className="flex flex-col space-y-1 py-1">
|
||||||
{activeNavigation.map((item) => (
|
{activeNavigation.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${
|
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${
|
||||||
item.current ? 'dark:border-l-dracula-pink' : ''
|
item.current ? "dark:border-l-dracula-pink" : ""
|
||||||
}`}
|
}`}
|
||||||
aria-current={item.current ? 'page' : undefined}
|
aria-current={item.current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</m.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const defaultNavigation = [
|
|||||||
|
|
||||||
const authedNavigation = [
|
const authedNavigation = [
|
||||||
{ name: 'Manage', href: '/manage', current: false },
|
{ name: 'Manage', href: '/manage', current: false },
|
||||||
]
|
];
|
||||||
|
|
||||||
export default async function NavBar(): Promise<React.JSX.Element> {
|
export default async function NavBar(): Promise<React.JSX.Element> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function PostHeader({metadata}: PostHeaderProps): React.JSX.Eleme
|
|||||||
<>
|
<>
|
||||||
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span>
|
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { DataSource } from "typeorm";
|
|
||||||
import { Photo } from "./entity/photo";
|
|
||||||
|
|
||||||
const dataSource = new DataSource({
|
|
||||||
type: "better-sqlite3",
|
|
||||||
database: "db.sql",
|
|
||||||
entities: [Photo],
|
|
||||||
migrations: ["./migrations"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default class PhotoDataSource {
|
|
||||||
private static _dataSource: DataSource | null = null;
|
|
||||||
|
|
||||||
static get dataSource(): Promise<DataSource> {
|
|
||||||
if (PhotoDataSource._dataSource === null) {
|
|
||||||
return PhotoDataSource.initDataSource();
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(PhotoDataSource._dataSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async initDataSource(): Promise<DataSource> {
|
|
||||||
if (!PhotoDataSource._dataSource || !PhotoDataSource._dataSource.isInitialized) {
|
|
||||||
const ds = await dataSource.initialize();
|
|
||||||
console.log('Photo data source initialized')
|
|
||||||
PhotoDataSource._dataSource = ds;
|
|
||||||
}
|
|
||||||
return PhotoDataSource._dataSource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PhotoDataSource.initDataSource();
|
|
||||||
3
src/db/db.ts
Normal file
3
src/db/db.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
|
||||||
|
export default drizzle(`${process.cwd()}/db.sql`);
|
||||||
22
src/db/schema/photo.ts
Normal file
22
src/db/schema/photo.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { int, sqliteTable, text, blob, real } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const photosTable = sqliteTable(
|
||||||
|
"photo",
|
||||||
|
{
|
||||||
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
|
src: text().notNull().unique(),
|
||||||
|
width: int().notNull(),
|
||||||
|
height: int().notNull(),
|
||||||
|
blur: blob().notNull(),
|
||||||
|
|
||||||
|
camera: text(),
|
||||||
|
title: text(),
|
||||||
|
description: text(),
|
||||||
|
exposureBiasValue: int(),
|
||||||
|
fNumber: real(),
|
||||||
|
isoSpeedRatings: int(),
|
||||||
|
focalLength: int(),
|
||||||
|
dateTimeOriginal: int({ mode: 'timestamp' }),
|
||||||
|
lensModel: text(),
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
export class Photo {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id!: number
|
|
||||||
|
|
||||||
@Column("text", { unique: true })
|
|
||||||
src!: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
width!: number
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
height!: number
|
|
||||||
|
|
||||||
@Column("blob")
|
|
||||||
blur!: string
|
|
||||||
|
|
||||||
@Column("text", { nullable: true })
|
|
||||||
camera: string | null = null;
|
|
||||||
|
|
||||||
// Manually input data
|
|
||||||
@Column("text", { nullable: true })
|
|
||||||
title: string | null = null;
|
|
||||||
|
|
||||||
@Column("text", { nullable: true })
|
|
||||||
description: string | null = null;
|
|
||||||
|
|
||||||
// Exif data
|
|
||||||
@Column("int", { nullable: true })
|
|
||||||
exposureBiasValue: number | null = null
|
|
||||||
|
|
||||||
@Column("float", { nullable: true })
|
|
||||||
fNumber: number | null = null
|
|
||||||
|
|
||||||
@Column("int", { nullable: true })
|
|
||||||
isoSpeedRatings: number | null = null
|
|
||||||
|
|
||||||
@Column("int", { nullable: true })
|
|
||||||
focalLength: number | null = null
|
|
||||||
|
|
||||||
@Column("date", { nullable: true })
|
|
||||||
dateTimeOriginal: Date | null = null
|
|
||||||
|
|
||||||
@Column("text", { nullable: true })
|
|
||||||
lensModel: string | null = null
|
|
||||||
}
|
|
||||||
82
src/games/games/MapGen/MapGenApp.ts
Normal file
82
src/games/games/MapGen/MapGenApp.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Control from '@/control/Control';
|
||||||
|
import Tile, { TileDir } from './Tile';
|
||||||
|
import Vector from '@/games/lib/utils/Vector';
|
||||||
|
import { min, shuffle } from 'radash';
|
||||||
|
|
||||||
|
const grid = new Vector(64, 256);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||||
|
*/
|
||||||
|
class TestGameApp extends BaseGameApp {
|
||||||
|
private _control: Control;
|
||||||
|
private _tileGrid: Tile[][] = Array.from({ length: grid.x }).map(() => Array.from({ length: grid.y }));
|
||||||
|
private _allTiles: Tile[] = Array.from({ length: grid.x * grid.y });
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement | null) {
|
||||||
|
super(canvas);
|
||||||
|
this._control = new Control();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
|
await super._start(canvas);
|
||||||
|
Registry.register('Control', this._control);
|
||||||
|
|
||||||
|
for (let x = 0; x < grid.x; x++) {
|
||||||
|
for (let y = 0; y < grid.y; y++) {
|
||||||
|
this._tileGrid[x][y] = new Tile({ x, y });
|
||||||
|
this._allTiles[x + y * grid.x] = this._tileGrid[x][y];
|
||||||
|
this._viewport.addChild(this._tileGrid[x][y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < grid.x; x++) {
|
||||||
|
for (let y = 0; y < grid.y; y++) {
|
||||||
|
if (x > 0) {
|
||||||
|
this._tileGrid[x][y].link(this._tileGrid[x - 1][y], TileDir.west);
|
||||||
|
}
|
||||||
|
if (x < grid.x - 1) {
|
||||||
|
this._tileGrid[x][y].link(this._tileGrid[x + 1][y], TileDir.east);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y > 0) {
|
||||||
|
this._tileGrid[x][y].link(this._tileGrid[x][y - 1], TileDir.north);
|
||||||
|
}
|
||||||
|
if (y < grid.y - 1) {
|
||||||
|
this._tileGrid[x][y].link(this._tileGrid[x][y + 1], TileDir.south);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < grid.x; x++) {
|
||||||
|
for (let y = 0; y < grid.y; y++) {
|
||||||
|
this._tileGrid[x][y].rescore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the logic of the game
|
||||||
|
*
|
||||||
|
* @param timeDelta - The time difference since the last update (in ms)
|
||||||
|
*/
|
||||||
|
protected async _update(timeDelta: number): Promise<void> {
|
||||||
|
await super._update(timeDelta);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
|
||||||
|
const activeTiles = this._allTiles.filter((tile) => !tile.selected && tile.score > 0);
|
||||||
|
if (activeTiles.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const lowestScore = min(activeTiles, t => t.score)!.score;
|
||||||
|
const lowestTiles = activeTiles.filter((tile) => tile.score === lowestScore);
|
||||||
|
const selectedTile = shuffle(lowestTiles)[0];
|
||||||
|
selectedTile.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestGameApp;
|
||||||
24
src/games/games/MapGen/MapGenWrapper.tsx
Normal file
24
src/games/games/MapGen/MapGenWrapper.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import MapGenApp from './MapGenApp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||||
|
*/
|
||||||
|
function TestGameWrapper(): React.ReactElement {
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const gameCreated = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameCreated.current) {
|
||||||
|
console.log('Creating game');
|
||||||
|
gameCreated.current = true;
|
||||||
|
new MapGenApp(canvas.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas ref={canvas} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestGameWrapper;
|
||||||
212
src/games/games/MapGen/Tile.ts
Normal file
212
src/games/games/MapGen/Tile.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Container from "@/games/lib/visual/Container";
|
||||||
|
import Graphics from "@/games/lib/visual/Graphics";
|
||||||
|
import Text from "@/games/lib/visual/Text";
|
||||||
|
|
||||||
|
const TileTypes = {
|
||||||
|
grass: "grass",
|
||||||
|
dirt: "dirt",
|
||||||
|
rock: "rock",
|
||||||
|
iron: "iron",
|
||||||
|
copper: "copper",
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum TileDir {
|
||||||
|
north,
|
||||||
|
east,
|
||||||
|
south,
|
||||||
|
west
|
||||||
|
};
|
||||||
|
|
||||||
|
type TileData = {
|
||||||
|
available: boolean,
|
||||||
|
colour: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileData: { [key in keyof typeof TileTypes]: TileData } = {
|
||||||
|
grass: {
|
||||||
|
available: false,
|
||||||
|
colour: 0x117c13
|
||||||
|
},
|
||||||
|
rock: {
|
||||||
|
available: false,
|
||||||
|
colour: 0xc6bfb8
|
||||||
|
},
|
||||||
|
dirt: {
|
||||||
|
available: false,
|
||||||
|
colour: 0x402905
|
||||||
|
},
|
||||||
|
copper: {
|
||||||
|
available: false,
|
||||||
|
colour: 0xb87333
|
||||||
|
},
|
||||||
|
iron: {
|
||||||
|
available: false,
|
||||||
|
colour: 0xa19d94
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Tile extends Container {
|
||||||
|
private static readonly size = {
|
||||||
|
x: 30,
|
||||||
|
y: 30
|
||||||
|
}
|
||||||
|
private _graphic: Graphics = new Graphics();
|
||||||
|
private _point: { x: number, y: number };
|
||||||
|
private _text: Text = new Text({
|
||||||
|
position: {
|
||||||
|
x: Tile.size.x / 2,
|
||||||
|
y: Tile.size.y / 2,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
private _choices = structuredClone(tileData);
|
||||||
|
private _selected: null | keyof typeof tileData = null;
|
||||||
|
public score = 0;
|
||||||
|
public neighbors: Tile[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor(point: { x: number, y: number }) {
|
||||||
|
super({
|
||||||
|
position: {
|
||||||
|
x: point.x * Tile.size.x,
|
||||||
|
y: point.y * Tile.size.y,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._point = point;
|
||||||
|
this.addChild(this._graphic, this._text);
|
||||||
|
|
||||||
|
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||||
|
this._text.text = "-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
link(tile: Tile, dir: TileDir) {
|
||||||
|
this.neighbors[dir] = tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._graphic.clear();
|
||||||
|
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||||
|
Object.values(this._choices).forEach((choice) => choice.available = false);
|
||||||
|
this._text.text = "-1";
|
||||||
|
this._point = {
|
||||||
|
x: -1,
|
||||||
|
y: -1
|
||||||
|
}
|
||||||
|
this._selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calculateAvailable() {
|
||||||
|
if (!this.neighbors[TileDir.north]) {
|
||||||
|
this._choices.grass.available = true;
|
||||||
|
this._choices.dirt.available = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.neighbors.forEach((neighbor) => {
|
||||||
|
if (neighbor.selected === 'grass') {
|
||||||
|
this._choices.dirt.available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neighbor.selected === 'dirt') {
|
||||||
|
this._choices.dirt.available = true;
|
||||||
|
this._choices.rock.available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neighbor.selected === 'rock') {
|
||||||
|
this._choices.rock.available = true;
|
||||||
|
this._choices.iron.available = true;
|
||||||
|
this._choices.copper.available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neighbor.selected === 'iron') {
|
||||||
|
this._choices.rock.available = true;
|
||||||
|
this._choices.iron.available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neighbor.selected === 'copper') {
|
||||||
|
this._choices.rock.available = true;
|
||||||
|
this._choices.copper.available = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this._point.y < 32) {
|
||||||
|
this._choices.iron.available = false;
|
||||||
|
}
|
||||||
|
if (this._point.y < 64) {
|
||||||
|
this._choices.copper.available = false;
|
||||||
|
this._choices.dirt.available = true;
|
||||||
|
this._choices.rock.available = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rescore(): number {
|
||||||
|
if (this._selected) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
this._calculateAvailable();
|
||||||
|
|
||||||
|
this.score = Object.values(this._choices).reduce((count, choice) => {
|
||||||
|
return choice.available ? count + 1 : count;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
this._text.text = this.score;
|
||||||
|
return this.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
select(): void {
|
||||||
|
|
||||||
|
//const available = Object.entries(this._choices).filter((choice) => choice[1].available);
|
||||||
|
//this._selected = shuffle(available)[0][0] as keyof typeof tileData;
|
||||||
|
const available = Object.entries(this._choices).filter((choice) => choice[1].available) as [keyof typeof TileTypes, TileData][];
|
||||||
|
|
||||||
|
const weightedAvailable = available.map(([tileType, tileData]) => {
|
||||||
|
const weightedEntry = {
|
||||||
|
type: tileType,
|
||||||
|
weight: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tileType === 'grass') {
|
||||||
|
weightedEntry.weight = 8;
|
||||||
|
}
|
||||||
|
else if (tileType === 'dirt') {
|
||||||
|
if (this._point.y === 0) {
|
||||||
|
weightedEntry.weight = 2;
|
||||||
|
} else {
|
||||||
|
weightedEntry.weight += 20 - Math.floor(((Math.min(this._point.y, 63) / 64) * 20) + 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (tileType === 'iron') {
|
||||||
|
weightedEntry.weight = 3;
|
||||||
|
}
|
||||||
|
else if (tileType === 'copper') {
|
||||||
|
weightedEntry.weight = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return weightedEntry;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalWeight = weightedAvailable.reduce((weight, avail) => weight + avail.weight, 0);
|
||||||
|
let pick = Math.floor(Math.random() * totalWeight)
|
||||||
|
|
||||||
|
for (let i = 0; i < weightedAvailable.length; i++) {
|
||||||
|
if (pick < weightedAvailable[i].weight) {
|
||||||
|
this._selected = weightedAvailable[i].type;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pick -= weightedAvailable[i].weight;
|
||||||
|
}
|
||||||
|
if (this._selected === null) {
|
||||||
|
throw new Error('Selection not done');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._graphic.clear();
|
||||||
|
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: this._choices[this._selected].colour });
|
||||||
|
this._text.text = "";
|
||||||
|
|
||||||
|
this.neighbors.forEach((neighbor) => neighbor.rescore());
|
||||||
|
}
|
||||||
|
|
||||||
|
get selected() {
|
||||||
|
return this._selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/games/games/TestGame/TestGameApp.ts
Normal file
41
src/games/games/TestGame/TestGameApp.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Control from '@/control/Control';
|
||||||
|
|
||||||
|
import Player from './components/Player/Player';
|
||||||
|
import Trees from './components/Trees';
|
||||||
|
import Walls from './components/Walls/Walls';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||||
|
*/
|
||||||
|
class TestGameApp extends BaseGameApp {
|
||||||
|
private _player!: Player;
|
||||||
|
private _control: Control;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement | null) {
|
||||||
|
super(canvas);
|
||||||
|
this._control = new Control();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
|
await super._start(canvas);
|
||||||
|
Registry.register('Control', this._control);
|
||||||
|
this._viewport.addChild(new Trees({ numberOfTrees: 200 }));
|
||||||
|
this._player = this._viewport.addChild(new Player());
|
||||||
|
this._viewport.addChild(new Walls());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the logic of the game
|
||||||
|
*
|
||||||
|
* @param timeDelta - The time difference since the last update (in ms)
|
||||||
|
*/
|
||||||
|
protected async _update(timeDelta: number): Promise<void> {
|
||||||
|
this._player.update(timeDelta);
|
||||||
|
await super._update(timeDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestGameApp;
|
||||||
24
src/games/games/TestGame/TestGameWrapper.tsx
Normal file
24
src/games/games/TestGame/TestGameWrapper.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import TestGameApp from './TestGameApp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||||
|
*/
|
||||||
|
function TestGameWrapper(): React.ReactElement {
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const gameCreated = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameCreated.current) {
|
||||||
|
console.log('Creating game');
|
||||||
|
gameCreated.current = true;
|
||||||
|
new TestGameApp(canvas.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas ref={canvas} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestGameWrapper;
|
||||||
132
src/games/games/TestGame/components/Player/Player.ts
Normal file
132
src/games/games/TestGame/components/Player/Player.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import Collidable from '@/control/Collidable';
|
||||||
|
import Rapier from '@/utils/Rapier';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Vector from '@/utils/Vector';
|
||||||
|
import Container from '@/visual/Container';
|
||||||
|
import Graphics from '@/visual/Graphics';
|
||||||
|
|
||||||
|
import BaseAttack from './attack/BaseAttack';
|
||||||
|
import Forward from './attack/Forward';
|
||||||
|
|
||||||
|
import type { Viewport } from '@/visual/pixi';
|
||||||
|
import type Control from '@/control/Control';
|
||||||
|
import { assign } from 'radash';
|
||||||
|
|
||||||
|
type PlayerSettings = ConstructorParameters<typeof Collidable>[0]
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Player extends Collidable {
|
||||||
|
private _control: Control;
|
||||||
|
private _characterGraphics: Graphics;
|
||||||
|
|
||||||
|
private _attacks: BaseAttack[] = [];
|
||||||
|
private _rapier: Rapier;
|
||||||
|
private _collider: InstanceType<Rapier['rapier']['Collider']>;
|
||||||
|
private _characterController: InstanceType<Rapier['rapier']['KinematicCharacterController']>;
|
||||||
|
|
||||||
|
constructor(settings?: PlayerSettings) {
|
||||||
|
super(assign(structuredClone(settings ?? {}), {
|
||||||
|
position: {
|
||||||
|
x: 960,
|
||||||
|
y: 540
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||||
|
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||||
|
this._control = Registry.fetch<Control>('Control');
|
||||||
|
|
||||||
|
this._characterGraphics = viewport.addChild(new Graphics());
|
||||||
|
this._characterGraphics.setStrokeStyle({
|
||||||
|
color: 0x00DD00,
|
||||||
|
width: 1
|
||||||
|
}).regularPoly(-10, -10, 20, 3).fill(0x00AA00).stroke();
|
||||||
|
this._characterGraphics.pivot.set(-10, -10);
|
||||||
|
this.addChild(this._characterGraphics);
|
||||||
|
|
||||||
|
viewport.follow(this, { radius: 250 });
|
||||||
|
viewport.addChild(this);
|
||||||
|
|
||||||
|
const projectileContainer = new Container();
|
||||||
|
viewport.addChild(projectileContainer);
|
||||||
|
const forwardAttack = new Forward(projectileContainer);
|
||||||
|
this._attacks.push(forwardAttack);
|
||||||
|
this.addChild(forwardAttack);
|
||||||
|
|
||||||
|
const trianglePoints = {
|
||||||
|
a: new Vector(0, -20),
|
||||||
|
b: new Vector(10 * Math.sqrt(3), 10),
|
||||||
|
c: new Vector(-10 * Math.sqrt(3), 10)
|
||||||
|
};
|
||||||
|
this._collider = this._rapier.createCollider(this, 'triangle', trianglePoints);
|
||||||
|
this._collider.setRestitution(0);
|
||||||
|
this._collider.setTranslation(this.position);
|
||||||
|
|
||||||
|
this._rapier.addColliderMemberGroups(this._collider, 'player');
|
||||||
|
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'enemyProjectile', 'wall');
|
||||||
|
|
||||||
|
this._characterController = this._rapier.world.createCharacterController(0.01);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
update(timeDelta: number): void {
|
||||||
|
let impulse = new Vector(0, 0);
|
||||||
|
let speed = 250;
|
||||||
|
|
||||||
|
if (this._control.isDown('up')) {
|
||||||
|
impulse.y -= 1;
|
||||||
|
}
|
||||||
|
if (this._control.isDown('down')) {
|
||||||
|
impulse.y += 1;
|
||||||
|
}
|
||||||
|
if (this._control.isDown('left')) {
|
||||||
|
impulse.x -= 1;
|
||||||
|
}
|
||||||
|
if (this._control.isDown('right')) {
|
||||||
|
impulse.x += 1;
|
||||||
|
}
|
||||||
|
if (this._control.isDown('shift')) {
|
||||||
|
speed *= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impulse.magnitude() > 0) {
|
||||||
|
const normImpulse = impulse.normalize();
|
||||||
|
this.rotation = Math.atan2(normImpulse.y, normImpulse.x) - Math.atan2(1, 0) + Math.PI;
|
||||||
|
impulse = impulse.normalize().multiplyScalar(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._characterController.computeColliderMovement(
|
||||||
|
this._collider,
|
||||||
|
impulse.multiplyScalar(timeDelta)
|
||||||
|
);
|
||||||
|
|
||||||
|
const correctedMovement = this._characterController.computedMovement();
|
||||||
|
this.position.add(correctedMovement);
|
||||||
|
// console.log('impulse', impulse);
|
||||||
|
// console.log('corrected', correctedMovement)
|
||||||
|
//this._collider.setTranslation(this.position);
|
||||||
|
|
||||||
|
|
||||||
|
if (this._control.isDown(' ')) {
|
||||||
|
const modifiedRotation = this.rotation - (Math.PI / 2);
|
||||||
|
const currentVector = new Vector(Math.cos(modifiedRotation), Math.sin(modifiedRotation));
|
||||||
|
this._attacks[0].fire({
|
||||||
|
playerVector: currentVector
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._attacks.forEach((attack) => {
|
||||||
|
attack.update(timeDelta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collide(collided: Collidable, colliding: boolean): void {
|
||||||
|
if (!colliding) {
|
||||||
|
console.log('player no longer colliding');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Player;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Container from '../../../../../lib/visual/Container';
|
||||||
|
import type Vector from '../../../../../lib/utils/Vector';
|
||||||
|
|
||||||
|
type fireState = {
|
||||||
|
playerVector: Vector
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BaseAttack extends Container {
|
||||||
|
protected _projectileContainer: Container;
|
||||||
|
|
||||||
|
constructor(projectileContainer: Container) {
|
||||||
|
super();
|
||||||
|
this._projectileContainer = projectileContainer;
|
||||||
|
}
|
||||||
|
abstract fire(state: fireState): void;
|
||||||
|
abstract update(timeDelta: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default BaseAttack;
|
||||||
159
src/games/games/TestGame/components/Player/attack/Forward.ts
Normal file
159
src/games/games/TestGame/components/Player/attack/Forward.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Collidable from '@/control/Collidable';
|
||||||
|
import Pool from '@/utils/Pool';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Vector from '@/utils/Vector';
|
||||||
|
import Graphics from '@/visual/Graphics';
|
||||||
|
|
||||||
|
import BaseAttack from './BaseAttack';
|
||||||
|
|
||||||
|
import type Rapier from '@/utils/Rapier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class ForwardProjectile extends Collidable {
|
||||||
|
private _speed: number = 600;
|
||||||
|
private _size: number = 5;
|
||||||
|
private _graphic: Graphics;
|
||||||
|
|
||||||
|
private _lifeTime: {
|
||||||
|
lifeTime: number,
|
||||||
|
lifeTimeRemaining: number
|
||||||
|
} = {
|
||||||
|
lifeTime: 2500,
|
||||||
|
lifeTimeRemaining: 0
|
||||||
|
};
|
||||||
|
private _collider!: InstanceType<Rapier['rapier']['Collider']>;
|
||||||
|
private _rigidBody!: InstanceType<Rapier['rapier']['RigidBody']>;
|
||||||
|
private _rapier: Rapier;
|
||||||
|
private _alive: boolean = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||||
|
this._graphic = new Graphics();
|
||||||
|
this._graphic.circle(this._size / 2, this._size / 2, this._size).fill(0xAA0000);
|
||||||
|
this.addChild(this._graphic);
|
||||||
|
this.pivot.set(this._size / 2, this._size / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
alive(): boolean {
|
||||||
|
return this._lifeTime.lifeTimeRemaining > 0 && this._alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
fire(vector: Vector): void {
|
||||||
|
this._alive = true;
|
||||||
|
this._addPhysics();
|
||||||
|
this._rigidBody.setTranslation(this.position, true);
|
||||||
|
this._rigidBody.setLinvel(vector.multiplyScalar(this._speed), true);
|
||||||
|
this._lifeTime.lifeTimeRemaining = this._lifeTime.lifeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param timeDelta - TODO
|
||||||
|
*/
|
||||||
|
update(timeDelta: number): void {
|
||||||
|
this.position.copyFrom(this._rigidBody.translation());
|
||||||
|
|
||||||
|
if (this._lifeTime.lifeTimeRemaining > 0) {
|
||||||
|
this._lifeTime.lifeTimeRemaining = Math.max(0, this._lifeTime.lifeTimeRemaining - timeDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets called when we've collided with something.
|
||||||
|
* Use to kill the projectile.
|
||||||
|
*
|
||||||
|
* @param collided - The Collidable this projectile has hit
|
||||||
|
* @param colliding - Are we currently colliding?
|
||||||
|
*/
|
||||||
|
collide(collided: Collidable, colliding: boolean): void {
|
||||||
|
if (colliding) {
|
||||||
|
this._alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
private _addPhysics(): void {
|
||||||
|
this._rigidBody = this._rapier.createRigidBody(this, 'Dynamic', false, true);
|
||||||
|
this._collider = this._rapier.createCollider(this, 'ball', { radius: this._size }, this._rigidBody);
|
||||||
|
this._collider.setRestitution(0);
|
||||||
|
this._rapier.addColliderMemberGroups(this._collider, 'playerProjectile');
|
||||||
|
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'wall');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
removePhysics(): void {
|
||||||
|
this._rapier.removeRigidBody(this._rigidBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Forward extends BaseAttack {
|
||||||
|
private _projectilePool: Pool<ForwardProjectile> = new Pool(ForwardProjectile, 50);
|
||||||
|
private _activeProjectiles: ForwardProjectile[] = [];
|
||||||
|
private _coolDown: {
|
||||||
|
coolDown: number,
|
||||||
|
coolDownRemaining: number
|
||||||
|
} = {
|
||||||
|
coolDown: 250,
|
||||||
|
coolDownRemaining: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
fire(state: Parameters<BaseAttack['fire']>[0]): void {
|
||||||
|
if (this._coolDown.coolDownRemaining > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newBullet = this._projectilePool.allocate();
|
||||||
|
|
||||||
|
const newPoint = this._projectileContainer.toLocal(new Vector(0, 0), this);
|
||||||
|
newBullet.position.copyFrom(newPoint);
|
||||||
|
this._projectileContainer.addChild(newBullet);
|
||||||
|
this._activeProjectiles.push(newBullet);
|
||||||
|
|
||||||
|
newBullet.fire(state.playerVector.clone());
|
||||||
|
|
||||||
|
this._coolDown.coolDownRemaining = this._coolDown.coolDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
update(timeDelta: number): void {
|
||||||
|
if (this._coolDown.coolDownRemaining > 0) {
|
||||||
|
this._coolDown.coolDownRemaining = Math.max(0, this._coolDown.coolDownRemaining - timeDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeProjectiles = this._activeProjectiles.filter((projectile) => {
|
||||||
|
projectile.update(timeDelta);
|
||||||
|
const alive = projectile.alive();
|
||||||
|
if (!alive) {
|
||||||
|
projectile.removePhysics();
|
||||||
|
this._projectileContainer.removeChild(projectile);
|
||||||
|
this._projectilePool.release(projectile);
|
||||||
|
}
|
||||||
|
return alive;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Forward;
|
||||||
|
|
||||||
31
src/games/games/TestGame/components/Trees.ts
Normal file
31
src/games/games/TestGame/components/Trees.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Pool from '@/utils/Pool';
|
||||||
|
import Container from '@/visual/Container';
|
||||||
|
import Graphics from '@/visual/Graphics';
|
||||||
|
import { PIXI } from '@/visual/pixi';
|
||||||
|
|
||||||
|
type TreesSettings = ConstructorParameters<typeof Container>[0] & {
|
||||||
|
numberOfTrees: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class Trees extends Container {
|
||||||
|
private readonly _allTrees: Graphics[] = new Array<Graphics>(200);
|
||||||
|
private readonly _treePool: Pool<Graphics>;
|
||||||
|
constructor(settings: TreesSettings) {
|
||||||
|
super(settings);
|
||||||
|
this._treePool = new Pool(Graphics, settings.numberOfTrees);
|
||||||
|
|
||||||
|
for (let i = 0; i < settings.numberOfTrees; i++) {
|
||||||
|
const tree = this.addChild(this._treePool.allocate());
|
||||||
|
const treeColour = ((Math.random() * 80) + 70);
|
||||||
|
tree.regularPoly(-10, 10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour - 10) / 255, 0]) });
|
||||||
|
tree.regularPoly(-10, 0, 20, 3).fill({ color: new PIXI.Color([0, treeColour / 255, 0]) });
|
||||||
|
tree.regularPoly(-10, -10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour + 10) / 255, 0]) });
|
||||||
|
tree.position.set(Math.random() * 1920, Math.random() * 1080);
|
||||||
|
tree.cullable = true;
|
||||||
|
this._allTrees.push(tree);
|
||||||
|
}
|
||||||
|
this.cullable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Trees;
|
||||||
76
src/games/games/TestGame/components/Walls/Walls.ts
Normal file
76
src/games/games/TestGame/components/Walls/Walls.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Collidable from '@/control/Collidable';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Container from '@/visual/Container';
|
||||||
|
import Graphics from '@/visual/Graphics';
|
||||||
|
import { PIXI } from '@/visual/pixi';
|
||||||
|
|
||||||
|
import type Rapier from '@/utils/Rapier';
|
||||||
|
|
||||||
|
type WallsSettings = ConstructorParameters<typeof Container>[0] & {
|
||||||
|
numberOfTrees: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type wallData = {
|
||||||
|
graphic: Graphics,
|
||||||
|
collider: InstanceType<Rapier['rapier']['Collider']>,
|
||||||
|
rigidBody: InstanceType<Rapier['rapier']['RigidBody']>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Walls extends Collidable {
|
||||||
|
private _rapier: Rapier;
|
||||||
|
private _walls: wallData[] = [];
|
||||||
|
|
||||||
|
constructor(settings?: WallsSettings) {
|
||||||
|
super(settings);
|
||||||
|
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||||
|
this._walls.push(this._createWall(10, 1110, -10, 540));
|
||||||
|
this._walls.push(this._createWall(10, 1110, 1930, 540));
|
||||||
|
this._walls.push(this._createWall(1950, 10, 960, -10));
|
||||||
|
this._walls.push(this._createWall(1950, 10, 960, 1090));
|
||||||
|
|
||||||
|
this._walls.forEach((wall) => {
|
||||||
|
this.addChild(wall.graphic);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param width - TODO
|
||||||
|
* @param height
|
||||||
|
* @param x
|
||||||
|
* @param y
|
||||||
|
*/
|
||||||
|
private _createWall(width: number, height: number, x: number, y: number): wallData {
|
||||||
|
const wall = new Graphics();
|
||||||
|
wall.rect(-width / 2, -height / 2, width, height).fill({ color: new PIXI.Color([0, 0, 0]) });
|
||||||
|
wall.position.set(x, y);
|
||||||
|
|
||||||
|
|
||||||
|
const rigidBody = this._rapier.createRigidBody(this, 'Fixed');
|
||||||
|
const collider = this._rapier.createCollider(this, 'cuboid', { halfHeight: height / 2, halfWidth: width / 2 }, rigidBody);
|
||||||
|
collider.setRestitution(0);
|
||||||
|
|
||||||
|
rigidBody.setTranslation(this.position.add({ x, y }), true);
|
||||||
|
|
||||||
|
this._rapier.addColliderMemberGroups(collider, 'wall');
|
||||||
|
this._rapier.addColliderFilterGroups(collider, 'player', 'enemy', 'enemyProjectile', 'playerProjectile');
|
||||||
|
|
||||||
|
return {
|
||||||
|
graphic: wall,
|
||||||
|
collider,
|
||||||
|
rigidBody
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
collide(collided: Collidable, colliding: boolean): void {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Walls;
|
||||||
7
src/games/lib/control/Collidable.ts
Normal file
7
src/games/lib/control/Collidable.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Container from '@/visual/Container';
|
||||||
|
|
||||||
|
abstract class Collidable extends Container {
|
||||||
|
abstract collide(collided: Collidable, colliding: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Collidable;
|
||||||
87
src/games/lib/control/Control.ts
Normal file
87
src/games/lib/control/Control.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
type state = 'down' | 'up';
|
||||||
|
|
||||||
|
// - TODO - Pull this from the actual game creating the controls
|
||||||
|
const GameControls = {
|
||||||
|
down: 'down',
|
||||||
|
up: 'up',
|
||||||
|
left: 'left',
|
||||||
|
right: 'right',
|
||||||
|
shift: 'shift',
|
||||||
|
' ': ' ',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for game controls, the game should say what controls it expects and then we can remap the actual controls separately.
|
||||||
|
* i.e. Both "ArrowDown" and "Down" from the keyboard listener should be the games "down" event.
|
||||||
|
* Also handles when the keys are pressed or not.
|
||||||
|
*/
|
||||||
|
class Control {
|
||||||
|
readonly gameControls = GameControls;
|
||||||
|
private _controlMap: { [key: string]: keyof typeof GameControls } = {
|
||||||
|
ArrowDown: GameControls.down,
|
||||||
|
Down: GameControls.down,
|
||||||
|
ArrowUp: GameControls.up,
|
||||||
|
Up: GameControls.up,
|
||||||
|
ArrowLeft: GameControls.left,
|
||||||
|
Left: GameControls.left,
|
||||||
|
ArrowRight: GameControls.right,
|
||||||
|
Right: GameControls.right,
|
||||||
|
Shift: GameControls.shift,
|
||||||
|
' ': GameControls[' '],
|
||||||
|
};
|
||||||
|
|
||||||
|
private _controlState: Map<keyof typeof GameControls, {
|
||||||
|
state: state,
|
||||||
|
|
||||||
|
consumed: boolean
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._setKeys();
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (this._controlState.get(this._controlMap[event.key])?.state !== 'down') {
|
||||||
|
this._controlState.set(this._controlMap[event.key], { state: 'down', consumed: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keyup', (event) => {
|
||||||
|
if (this._controlState.get(this._controlMap[event.key])?.state !== 'up') {
|
||||||
|
this._controlState.set(this._controlMap[event.key], { state: 'up', consumed: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
private _setKeys(): void {
|
||||||
|
Object.keys(GameControls).forEach((key) => {
|
||||||
|
this._controlState.set(key as keyof typeof GameControls, { state: 'up', consumed: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param key - TODO
|
||||||
|
*/
|
||||||
|
isDown(key: keyof typeof GameControls): boolean {
|
||||||
|
return this._controlState.get(key)?.state === 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume a down key event once
|
||||||
|
*
|
||||||
|
* @param key - TODO
|
||||||
|
*/
|
||||||
|
onceDown(key: keyof typeof GameControls): boolean {
|
||||||
|
const keyState = this._controlState.get(key);
|
||||||
|
if (keyState && keyState.state === 'down' && !keyState.consumed) {
|
||||||
|
keyState.consumed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Control;
|
||||||
96
src/games/lib/core/BaseGameApp/BaseGameApp.ts
Normal file
96
src/games/lib/core/BaseGameApp/BaseGameApp.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { PIXI, Viewport } from '@/visual/pixi';
|
||||||
|
import Text from '@/visual/Text';
|
||||||
|
import Rapier from '@/utils/Rapier';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import Ticker from '@/utils/Ticker';
|
||||||
|
import Vector from '@/utils/Vector';
|
||||||
|
import config from './BaseGameAppConfig';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class BaseGameApp {
|
||||||
|
protected readonly _app: PIXI.Application;
|
||||||
|
protected readonly _ticker: Ticker = new Ticker();
|
||||||
|
protected _fps: Text = new Text(config.fps);
|
||||||
|
protected _viewport!: Viewport;
|
||||||
|
protected _rapier!: Rapier;
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement | null) {
|
||||||
|
if (canvas === null) {
|
||||||
|
throw new Error('No canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._app = new PIXI.Application();
|
||||||
|
|
||||||
|
void this._start(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
|
await this._app.init({
|
||||||
|
backgroundColor: 0x999999,
|
||||||
|
canvas: canvas,
|
||||||
|
antialias: true,
|
||||||
|
resizeTo: canvas.parentElement as HTMLElement
|
||||||
|
});
|
||||||
|
|
||||||
|
this._app.ticker.autoStart = false;
|
||||||
|
this._app.ticker.stop();
|
||||||
|
|
||||||
|
this._viewport = new Viewport({
|
||||||
|
screenWidth: this._app.canvas.width,
|
||||||
|
screenHeight: this._app.canvas.height,
|
||||||
|
worldWidth: 1920,
|
||||||
|
worldHeight: 1080,
|
||||||
|
passiveWheel: false,
|
||||||
|
noTicker: true,
|
||||||
|
events: this._app.renderer.events
|
||||||
|
});
|
||||||
|
Registry.register('Viewport', this._viewport);
|
||||||
|
this._rapier = new Rapier(new Vector(0, 0));
|
||||||
|
Registry.register('Rapier', this._rapier);
|
||||||
|
|
||||||
|
this._viewport.ensureVisible(0, 0, 1920, 1080, true);
|
||||||
|
this._viewport.fitWorld(true);
|
||||||
|
this._viewport.drag();
|
||||||
|
|
||||||
|
this._app.stage.addChild(this._viewport);
|
||||||
|
this._app.stage.addChild(this._fps);
|
||||||
|
|
||||||
|
await this._rapier.setup();
|
||||||
|
|
||||||
|
this._ticker.ticker(1000 / 60, this._update.bind(this), this._updateComplete.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the logic of the game
|
||||||
|
* - TODO - Figure out if this should be called before or after game updates
|
||||||
|
*
|
||||||
|
* @param timeDelta - The time difference since the last update (in ms)
|
||||||
|
*/
|
||||||
|
protected async _update(timeDelta: number): Promise<void> {
|
||||||
|
this._rapier.debugRender();
|
||||||
|
this._viewport.update(timeDelta);
|
||||||
|
this._rapier.update();
|
||||||
|
this._fps.text = this._ticker.currentTicksPerSecond;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the update is complete, kick off a render
|
||||||
|
*
|
||||||
|
* @param updated - Was there actually an update in the last raf?
|
||||||
|
*/
|
||||||
|
protected async _updateComplete(updated: boolean): Promise<void> {
|
||||||
|
this._viewport.resize(this._app.canvas.width, this._app.canvas.height);
|
||||||
|
this._viewport.fitWorld(true);
|
||||||
|
this._app.renderer.render(this._app.stage);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseGameApp;
|
||||||
10
src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
Normal file
10
src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const BaseGameAppConfig = {
|
||||||
|
fps: {
|
||||||
|
position: {
|
||||||
|
x: 20,
|
||||||
|
y: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default BaseGameAppConfig;
|
||||||
31
src/games/lib/utils/Pool.ts
Normal file
31
src/games/lib/utils/Pool.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ObjectPoolFactory } from '@pixi-essentials/object-pool';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Pool<T> {
|
||||||
|
private _pool: ObjectPoolFactory<T>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
constructor(ctor: new (...args: any[]) => T, reserve: number) {
|
||||||
|
// Unsure why this needs the cast, probably because of the generic
|
||||||
|
this._pool = ObjectPoolFactory.build(ctor) as ObjectPoolFactory<T>;
|
||||||
|
this._pool.reserve(reserve);
|
||||||
|
this._pool.startGC();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
allocate(): T {
|
||||||
|
return this._pool.allocate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
release(object: T): void {
|
||||||
|
this._pool.release(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pool;
|
||||||
223
src/games/lib/utils/Rapier.ts
Normal file
223
src/games/lib/utils/Rapier.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import StoredPromise from './StoredPromise';
|
||||||
|
import Vector from './Vector';
|
||||||
|
import Collidable from '../control/Collidable';
|
||||||
|
import _rapier from '@dimforge/rapier2d-compat';
|
||||||
|
import Graphics from '@/visual/Graphics';
|
||||||
|
import Registry from '@/utils/Registry';
|
||||||
|
import { PIXI, type Viewport } from '@/visual/pixi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
* https://rapier.rs/docs/user_guides/javascript/colliders#overview
|
||||||
|
*/
|
||||||
|
type colliderTypes = {
|
||||||
|
ball: {
|
||||||
|
radius: number
|
||||||
|
},
|
||||||
|
cuboid: {
|
||||||
|
halfHeight: number,
|
||||||
|
halfWidth: number
|
||||||
|
},
|
||||||
|
capsule: {
|
||||||
|
halfHeight: number,
|
||||||
|
radius: number
|
||||||
|
},
|
||||||
|
triangle: {
|
||||||
|
a: Vector,
|
||||||
|
b: Vector,
|
||||||
|
c: Vector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colliderGroups = {
|
||||||
|
'wall': 0b0000_0000_0000_0001,
|
||||||
|
'player': 0b0000_0000_0001_0000,
|
||||||
|
'enemy': 0b0000_0000_0010_0000,
|
||||||
|
'playerProjectile': 0b0000_0001_0000_0000,
|
||||||
|
'enemyProjectile': 0b0000_0010_0000_0000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Rapier {
|
||||||
|
private _settingUp!: StoredPromise<void>;
|
||||||
|
private _world!: _rapier.World;
|
||||||
|
private _eventQueue!: _rapier.EventQueue;
|
||||||
|
private _rigidBodyMap: Map<number, Collidable> = new Map();
|
||||||
|
private _colliderMap: Map<number, Collidable> = new Map();
|
||||||
|
private _debugLines: Graphics;
|
||||||
|
|
||||||
|
constructor(gravity: Vector) {
|
||||||
|
void this._setup(gravity);
|
||||||
|
this._debugLines = new Graphics();
|
||||||
|
}
|
||||||
|
|
||||||
|
get rapier(): typeof _rapier {
|
||||||
|
return _rapier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get world(): _rapier.World {
|
||||||
|
return this._world;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needed as rapier2d is a webasm module that needs to be async imported directly
|
||||||
|
*/
|
||||||
|
private async _setup(gravity: Vector): Promise<void> {
|
||||||
|
this._settingUp = new StoredPromise();
|
||||||
|
await this.rapier.init()
|
||||||
|
// - TODO - Change integration params dt to ticker speed
|
||||||
|
this._world = new this.rapier.World(gravity);
|
||||||
|
this._eventQueue = new this.rapier.EventQueue(true);
|
||||||
|
|
||||||
|
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||||
|
viewport.addChild(this._debugLines);
|
||||||
|
|
||||||
|
this._settingUp.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
async setup(): Promise<void> {
|
||||||
|
await this._settingUp.promise;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
createRigidBody(container: Collidable, type: keyof typeof _rapier.RigidBodyType, sleep: boolean = true, CCD: boolean = false): _rapier.RigidBody {
|
||||||
|
const rigidBodyDesc = new this.rapier.RigidBodyDesc(this.rapier.RigidBodyType[type])
|
||||||
|
.setCanSleep(sleep)
|
||||||
|
.setCcdEnabled(CCD);
|
||||||
|
|
||||||
|
const rigidBody = this._world.createRigidBody(rigidBodyDesc);
|
||||||
|
this._rigidBodyMap.set(rigidBody.handle, container);
|
||||||
|
return rigidBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
createCollider<T extends keyof colliderTypes, U extends colliderTypes[T]>(container: Collidable, type: T, settings: U, rigidBody?: _rapier.RigidBody): _rapier.Collider {
|
||||||
|
let colliderDesc: _rapier.ColliderDesc;
|
||||||
|
if (type === 'ball') {
|
||||||
|
const castSettings = (settings as colliderTypes['ball']);
|
||||||
|
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Ball(castSettings.radius));
|
||||||
|
} else if (type === 'capsule') {
|
||||||
|
const castSettings = (settings as colliderTypes['capsule']);
|
||||||
|
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Capsule(castSettings.halfHeight, castSettings.radius));
|
||||||
|
} else if (type === 'cuboid') {
|
||||||
|
const castSettings = (settings as colliderTypes['cuboid']);
|
||||||
|
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Cuboid(castSettings.halfWidth, castSettings.halfHeight));
|
||||||
|
} else if (type === 'triangle') {
|
||||||
|
const castSettings = (settings as colliderTypes['triangle']);
|
||||||
|
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Triangle(castSettings.a, castSettings.b, castSettings.c));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Cannot create collider of type ${type}`);
|
||||||
|
}
|
||||||
|
colliderDesc.setActiveEvents(this.rapier.ActiveEvents.COLLISION_EVENTS);
|
||||||
|
colliderDesc.setActiveCollisionTypes(this.rapier.ActiveCollisionTypes.DEFAULT);
|
||||||
|
|
||||||
|
const collider = this._world.createCollider(colliderDesc, rigidBody);
|
||||||
|
collider.setCollisionGroups(0);
|
||||||
|
collider.setFriction(0);
|
||||||
|
|
||||||
|
this._colliderMap.set(collider.handle, container);
|
||||||
|
|
||||||
|
return collider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
removeRigidBody(rigidBody: _rapier.RigidBody): void {
|
||||||
|
if (rigidBody) {
|
||||||
|
this._world.removeRigidBody(rigidBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
removeCollider(collider: _rapier.Collider): void {
|
||||||
|
if (collider) {
|
||||||
|
this._world.removeCollider(collider, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the collider to member groups.
|
||||||
|
* A member group is what groups we're in (i.e. so other groups can collide with us).
|
||||||
|
*
|
||||||
|
* @param collider - The collider to add the groups to
|
||||||
|
* @param groups - The groups to add to
|
||||||
|
*/
|
||||||
|
addColliderMemberGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||||
|
const bitmask = this._getColliderGroupsBitmask(groups) << 16;
|
||||||
|
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the collider to filter groups.
|
||||||
|
* A filter group is what groups we can collide with.
|
||||||
|
*
|
||||||
|
* @param collider - The collider to add the groups to
|
||||||
|
* @param groups - The groups to add to
|
||||||
|
*/
|
||||||
|
addColliderFilterGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||||
|
const bitmask = this._getColliderGroupsBitmask(groups);
|
||||||
|
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
private _getColliderGroupsBitmask(groups: (keyof typeof colliderGroups)[]): number {
|
||||||
|
return groups.reduce((bitmask, group) => {
|
||||||
|
return bitmask | colliderGroups[group];
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
update(): void {
|
||||||
|
this._world.step(this._eventQueue);
|
||||||
|
this._eventQueue.drainCollisionEvents((handle1, handle2, colliding) => {
|
||||||
|
const container1 = this._colliderMap.get(handle1);
|
||||||
|
const container2 = this._colliderMap.get(handle2);
|
||||||
|
if (container1 && container2) {
|
||||||
|
container1.collide(container2, colliding);
|
||||||
|
container2.collide(container1, colliding);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debugRender(): void {
|
||||||
|
const { vertices, colors } = this._world.debugRender();
|
||||||
|
|
||||||
|
this._debugLines.clear();
|
||||||
|
|
||||||
|
for (let i = 0; i < vertices.length / 4; i += 1) {
|
||||||
|
let color = new PIXI.Color([
|
||||||
|
colors[i * 8],
|
||||||
|
colors[i * 8 + 1],
|
||||||
|
colors[i * 8 + 2],
|
||||||
|
]);
|
||||||
|
this._debugLines.setStrokeStyle({
|
||||||
|
color: color,
|
||||||
|
width: 2,
|
||||||
|
alignment: 0.5,
|
||||||
|
})
|
||||||
|
this._debugLines.moveTo(vertices[i * 4], vertices[i * 4 + 1]);
|
||||||
|
this._debugLines.lineTo(vertices[i * 4 + 2], vertices[i * 4 + 3]);
|
||||||
|
this._debugLines.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Rapier;
|
||||||
32
src/games/lib/utils/Registry.ts
Normal file
32
src/games/lib/utils/Registry.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class Registry {
|
||||||
|
static _objects: Map<string, unknown> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param name - TODO
|
||||||
|
* @param obj - TODO
|
||||||
|
*/
|
||||||
|
static register<T>(name: string, obj: T): void {
|
||||||
|
this._objects.set(name, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param name - TODO
|
||||||
|
*/
|
||||||
|
static fetch<T>(name: string): T {
|
||||||
|
const obj = this._objects.get(name) as T;
|
||||||
|
if (obj) {
|
||||||
|
return obj;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Cannot find ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Registry;
|
||||||
31
src/games/lib/utils/StoredPromise.ts
Normal file
31
src/games/lib/utils/StoredPromise.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class StoredPromise<T = void> {
|
||||||
|
promise: null | Promise<T>;
|
||||||
|
private _resolve: null | (( value: T | PromiseLike<T> ) => void);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private _reject: null | (( reason?: any ) => void);
|
||||||
|
constructor() {
|
||||||
|
this.promise = new Promise<T>((resolve, reject) => {
|
||||||
|
this._resolve = resolve;
|
||||||
|
this._reject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value: T | PromiseLike<T>): void {
|
||||||
|
this._resolve?.(value);
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
reject( reason?: any ): void {
|
||||||
|
this._reject?.(reason);
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.promise = null;
|
||||||
|
this._resolve = null;
|
||||||
|
this._reject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StoredPromise;
|
||||||
75
src/games/lib/utils/Ticker.ts
Normal file
75
src/games/lib/utils/Ticker.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* A ticker class to call a callback on a set time\
|
||||||
|
* Is catch-up tick based so will call update multiple times
|
||||||
|
*/
|
||||||
|
class Ticker {
|
||||||
|
private _tickUpdateCb: (timeDelta: number) => Promise<void> = async () => {};
|
||||||
|
private _tickCompleteCb: (updated: boolean) => Promise<void> = async () => {};
|
||||||
|
private _voidUpdate: ( frameTime: number ) => void;
|
||||||
|
private _tickCounter: number[] = [];
|
||||||
|
private _tickTime = 0;
|
||||||
|
private _lastFrameTime = 0;
|
||||||
|
private _currentTotalDelta = 0;
|
||||||
|
private _ticksPerSecond = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._voidUpdate = (frameTime: number): void => {
|
||||||
|
void this._update(frameTime);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTicksPerSecond(): number {
|
||||||
|
return this._ticksPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the ticker to update every tickTime\
|
||||||
|
* Will use requestAnimationFrame and catch up if needed\
|
||||||
|
* Use the tick callback for logic updates and the complete callback for visual representations of that logic
|
||||||
|
*/
|
||||||
|
ticker(tickTime: number, tickCb: (timeDelta: number) => Promise<void>, completeCb: (updated: boolean) => Promise<void>): void {
|
||||||
|
this._tickTime = tickTime;
|
||||||
|
this._lastFrameTime = performance.now();
|
||||||
|
this._tickUpdateCb = tickCb;
|
||||||
|
this._tickCompleteCb = completeCb;
|
||||||
|
requestAnimationFrame(this._voidUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update loop on raf, call the update multiple times to catch up, then call raf again
|
||||||
|
*/
|
||||||
|
private async _update(currentFrameTime: number): Promise<void> {
|
||||||
|
const delta = currentFrameTime - this._lastFrameTime;
|
||||||
|
this._lastFrameTime = currentFrameTime;
|
||||||
|
this._currentTotalDelta += delta;
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
/**
|
||||||
|
* - TODO - This min number probably needs to change, and reset the time differently
|
||||||
|
* This way will reset on the next frame, do we want to allow a min frame for update/render purposes but run all the ticks?
|
||||||
|
* Probably depends on the game.
|
||||||
|
*/
|
||||||
|
let ticks = Math.min(Math.floor(this._currentTotalDelta / this._tickTime), 100);
|
||||||
|
this._currentTotalDelta = this._currentTotalDelta % this._tickTime;
|
||||||
|
while (ticks > 0) {
|
||||||
|
updated = true;
|
||||||
|
ticks -= 1;
|
||||||
|
await this._tickUpdateCb(this._tickTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
this._tickCounter.push(currentFrameTime);
|
||||||
|
const minusOneSecond = currentFrameTime - 1000;
|
||||||
|
while (this._tickCounter[0] < minusOneSecond) {
|
||||||
|
this._tickCounter.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ticksPerSecond = this._tickCounter.length;
|
||||||
|
}
|
||||||
|
await this._tickCompleteCb(updated);
|
||||||
|
|
||||||
|
requestAnimationFrame(this._voidUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Ticker;
|
||||||
7
src/games/lib/utils/Vector.ts
Normal file
7
src/games/lib/utils/Vector.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PIXI } from '@/visual/pixi';
|
||||||
|
|
||||||
|
class Vector extends PIXI.Point {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Vector;
|
||||||
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
Normal file
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
declare module '@pixi-essentials/object-pool' {
|
||||||
|
class ObjectPoolFactory<T> {
|
||||||
|
static build: (ctor: new (args: unknown[]) => T) => ObjectPoolFactory<T>;
|
||||||
|
reserve: (reserve: number) => void;
|
||||||
|
startGC: () => void;
|
||||||
|
allocate: () => T;
|
||||||
|
release: (obj: T) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/games/lib/visual/Container.ts
Normal file
23
src/games/lib/visual/Container.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Vector from '@/utils/Vector';
|
||||||
|
import { PIXI } from './pixi';
|
||||||
|
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||||
|
|
||||||
|
type ContainerSettings = VisualBaseSettings;
|
||||||
|
|
||||||
|
class Container extends PIXI.Container {
|
||||||
|
constructor(settings?: ContainerSettings) {
|
||||||
|
super();
|
||||||
|
VisualBase.applySettings(this, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch this container's parent to a new parent, keeping it's positioning
|
||||||
|
*/
|
||||||
|
addToNewParent(newParent: Container): void {
|
||||||
|
const localPoint = newParent.toLocal(new Vector(0, 0), this);
|
||||||
|
this.position.set(localPoint.x, localPoint.y);
|
||||||
|
newParent.addChild(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Container;
|
||||||
13
src/games/lib/visual/Graphics.ts
Normal file
13
src/games/lib/visual/Graphics.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PIXI } from './pixi';
|
||||||
|
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||||
|
|
||||||
|
type GraphicsSettings = VisualBaseSettings;
|
||||||
|
|
||||||
|
class Graphics extends PIXI.Graphics {
|
||||||
|
constructor(settings?: GraphicsSettings ) {
|
||||||
|
super();
|
||||||
|
VisualBase.applySettings(this, settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Graphics;
|
||||||
29
src/games/lib/visual/Text.ts
Normal file
29
src/games/lib/visual/Text.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { assign } from 'radash';
|
||||||
|
import { PIXI } from './pixi';
|
||||||
|
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||||
|
|
||||||
|
type TextSettings = VisualBaseSettings & {
|
||||||
|
text?: string | number,
|
||||||
|
style?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Text extends PIXI.Text {
|
||||||
|
static styles: { [key: string]: (Partial<PIXI.TextStyle> | PIXI.TextStyle) } = {
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
fill: 0x000000,
|
||||||
|
align: 'center',
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(settings?: TextSettings, style?: Partial<PIXI.TextStyle> | PIXI.TextStyle) {
|
||||||
|
super({
|
||||||
|
text: settings?.text,
|
||||||
|
style: assign(Text.styles[settings?.style ?? 'default'], style ?? {})
|
||||||
|
})
|
||||||
|
VisualBase.applySettings(this, settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Text;
|
||||||
93
src/games/lib/visual/VisualBase.ts
Normal file
93
src/games/lib/visual/VisualBase.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { PIXI } from './pixi';
|
||||||
|
|
||||||
|
type pointOrNumber = {
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
} | number | undefined | PIXI.Point;
|
||||||
|
|
||||||
|
type VisualBaseSettings = {
|
||||||
|
angle?: number,
|
||||||
|
pivot?: pointOrNumber
|
||||||
|
position?: {
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
},
|
||||||
|
rotation?: number,
|
||||||
|
scale?: pointOrNumber
|
||||||
|
skew?: pointOrNumber,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
alpha?: number,
|
||||||
|
visible?: boolean,
|
||||||
|
interactive?: boolean,
|
||||||
|
zIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*/
|
||||||
|
class VisualBase {
|
||||||
|
static applySettings<T extends PIXI.Container>(applyTo: T, settings?: VisualBaseSettings): void {
|
||||||
|
if (settings?.angle) {
|
||||||
|
applyTo.angle = settings.angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.position) {
|
||||||
|
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.rotation) {
|
||||||
|
applyTo.rotation = settings.rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.interactive) {
|
||||||
|
applyTo.interactive = settings.interactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.zIndex) {
|
||||||
|
applyTo.zIndex = settings.zIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.alpha) {
|
||||||
|
applyTo.alpha = settings.alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.visible) {
|
||||||
|
applyTo.visible = settings.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.position) {
|
||||||
|
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPointPrimitiveOrObject('pivot', applyTo, settings?.pivot);
|
||||||
|
applyPointPrimitiveOrObject('scale', applyTo, settings?.scale);
|
||||||
|
applyPointPrimitiveOrObject('skew', applyTo, settings?.skew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - TODO
|
||||||
|
*
|
||||||
|
* @param prop - TODO
|
||||||
|
* @param applyTo - TODO
|
||||||
|
* @param setting - TODO
|
||||||
|
*/
|
||||||
|
function applyPointPrimitiveOrObject<T extends PIXI.Container>(prop: 'skew' | 'pivot' | 'scale', applyTo: T, setting: pointOrNumber): void {
|
||||||
|
if (setting) {
|
||||||
|
if (typeof setting === 'object') {
|
||||||
|
applyTo[prop].set(setting.x, setting.y);
|
||||||
|
} else {
|
||||||
|
applyTo[prop].set(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
VisualBase
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
VisualBaseSettings
|
||||||
|
};
|
||||||
11
src/games/lib/visual/pixi.ts
Normal file
11
src/games/lib/visual/pixi.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// - TODO - Clean this up
|
||||||
|
import * as PIXI from 'pixi.js';
|
||||||
|
import * as Events from '@pixi/events';
|
||||||
|
import { Viewport } from 'pixi-viewport';
|
||||||
|
|
||||||
|
import 'pixi.js/math-extras';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
import * as Filters from 'pixi-filters';
|
||||||
|
|
||||||
|
export { PIXI, Events, Viewport, Filters };
|
||||||
@@ -13,4 +13,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
}],
|
}],
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
redirectProxyUrl: `${getCurrentUrl()}/api/auth`,
|
redirectProxyUrl: `${getCurrentUrl()}/api/auth`,
|
||||||
})
|
});
|
||||||
60
src/trpc/client.tsx
Normal file
60
src/trpc/client.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { httpBatchLink } from "@trpc/client";
|
||||||
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
|
import { getCurrentUrl } from "@/lib/current-url";
|
||||||
|
import { makeQueryClient } from "./query-client";
|
||||||
|
|
||||||
|
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { appRouter } from "./routers/_app";
|
||||||
|
|
||||||
|
export const trpc = createTRPCReact<typeof appRouter>();
|
||||||
|
|
||||||
|
let clientQueryClientSingleton: QueryClient;
|
||||||
|
|
||||||
|
function getQueryClient(): QueryClient {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
// Server: always make a new query client
|
||||||
|
return makeQueryClient();
|
||||||
|
}
|
||||||
|
// Browser: use singleton pattern to keep the same query client
|
||||||
|
return (clientQueryClientSingleton ??= makeQueryClient());
|
||||||
|
}
|
||||||
|
function getUrl(): string {
|
||||||
|
const base = ((): string => {
|
||||||
|
if (typeof window !== "undefined") return "";
|
||||||
|
return getCurrentUrl();
|
||||||
|
})();
|
||||||
|
return `${base}/api/trpc`;
|
||||||
|
}
|
||||||
|
export function TRPCProvider(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>
|
||||||
|
): React.JSX.Element {
|
||||||
|
// NOTE: Avoid useState when initializing the query client if you don't
|
||||||
|
// have a suspense boundary between this and the code that may
|
||||||
|
// suspend because React will throw away the client on the initial
|
||||||
|
// render if it suspends and there is no boundary
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
const [trpcClient] = useState(() =>
|
||||||
|
trpc.createClient({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
transformer: superjson,
|
||||||
|
url: getUrl(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{props.children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/trpc/init.ts
Normal file
49
src/trpc/init.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
import superjson from 'superjson';
|
||||||
|
import { initTRPC, TRPCError } from '@trpc/server';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
user?: {
|
||||||
|
id?: string
|
||||||
|
name?: string | null
|
||||||
|
email?: string | null
|
||||||
|
image?: string | null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTRPCContext = cache(async (): Promise<Context> => {
|
||||||
|
/**
|
||||||
|
* @see: https://trpc.io/docs/server/context
|
||||||
|
*/
|
||||||
|
const session = await auth();
|
||||||
|
return { user: session?.user };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avoid exporting the entire t-object
|
||||||
|
// since it's not very descriptive.
|
||||||
|
// For instance, the use of a t variable
|
||||||
|
// is common in i18n libraries.
|
||||||
|
const t = initTRPC.context<Context>().create({
|
||||||
|
/**
|
||||||
|
* @see https://trpc.io/docs/server/data-transformers
|
||||||
|
*/
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authMiddleware = t.middleware(({ ctx, next }) => {
|
||||||
|
if (ctx.user?.name !== 'Joe') {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
user: ctx.user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Base router and procedure helpers
|
||||||
|
export const createTRPCRouter = t.router;
|
||||||
|
export const createCallerFactory = t.createCallerFactory;
|
||||||
|
export const publicProcedure = t.procedure;
|
||||||
|
export const privateProcedure = t.procedure.use(authMiddleware);
|
||||||
27
src/trpc/query-client.ts
Normal file
27
src/trpc/query-client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
defaultShouldDehydrateQuery,
|
||||||
|
QueryClient,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import { serialize, deserialize } from 'superjson';
|
||||||
|
|
||||||
|
export function makeQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
},
|
||||||
|
dehydrate: {
|
||||||
|
serializeData: serialize,
|
||||||
|
shouldDehydrateQuery: (query) =>
|
||||||
|
defaultShouldDehydrateQuery(query) ||
|
||||||
|
query.state.status === 'pending',
|
||||||
|
},
|
||||||
|
hydrate: {
|
||||||
|
deserializeData: deserialize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/trpc/routers/_app.ts
Normal file
11
src/trpc/routers/_app.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// eslint-disable-next-line import/named
|
||||||
|
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||||
|
import { createTRPCRouter } from '../init';
|
||||||
|
import { photosRouter } from './photos';
|
||||||
|
|
||||||
|
export const appRouter = createTRPCRouter({
|
||||||
|
photos: photosRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RouterInput = inferRouterInputs<typeof appRouter>;
|
||||||
|
export type RouterOutput = inferRouterOutputs<typeof appRouter>;
|
||||||
35
src/trpc/routers/photos.ts
Normal file
35
src/trpc/routers/photos.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { createTRPCRouter, privateProcedure, publicProcedure } from '../init';
|
||||||
|
|
||||||
|
import { list } from './photos/list';
|
||||||
|
import { update } from './photos/update';
|
||||||
|
|
||||||
|
export const photosRouter = createTRPCRouter({
|
||||||
|
list: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.number().nonnegative().default(1),
|
||||||
|
cursor: z.number().nonnegative().default(0),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const ret = await list({
|
||||||
|
limit: input.limit + 1,
|
||||||
|
cursor: input.cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
let next;
|
||||||
|
if (ret.length > input.limit) {
|
||||||
|
next = input.limit;
|
||||||
|
ret.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: ret,
|
||||||
|
next
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
update: privateProcedure.query(update)
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { shake } from "radash";
|
import { shake } from "radash";
|
||||||
import PhotoDataSource from "@/data-source";
|
import db from "@/db/db";
|
||||||
import { Photo } from "@/entity/photo";
|
import { photosTable } from "@/db/schema/photo";
|
||||||
|
|
||||||
export type ImageData = {
|
export type ImageData = {
|
||||||
width: number,
|
width: number,
|
||||||
@@ -21,17 +20,16 @@ export type ImageData = {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetPhotos = {
|
export type ListOptions = {
|
||||||
status: number,
|
cursor: number,
|
||||||
data: {
|
limit: number
|
||||||
images: ImageData[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(): Promise<Response> {
|
export async function list(options: ListOptions): Promise<ImageData[]> {
|
||||||
const dataSource = await PhotoDataSource.dataSource;
|
const currentSources = await db.select().from(photosTable)
|
||||||
const photoRepository = dataSource.getRepository(Photo);
|
.limit(options.limit)
|
||||||
const currentSources = await photoRepository.find();
|
.offset(options.cursor);
|
||||||
|
|
||||||
const images = currentSources.map((photo) => {
|
const images = currentSources.map((photo) => {
|
||||||
return {
|
return {
|
||||||
width: photo.width,
|
width: photo.width,
|
||||||
@@ -49,8 +47,8 @@ export async function GET(): Promise<Response> {
|
|||||||
}),
|
}),
|
||||||
title: photo.title ?? undefined,
|
title: photo.title ?? undefined,
|
||||||
description: photo.description ?? undefined
|
description: photo.description ?? undefined
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
return NextResponse.json<GetPhotos>({ status: 200, data: { images } });
|
return images;
|
||||||
}
|
}
|
||||||
82
src/trpc/routers/photos/update/index.ts
Normal file
82
src/trpc/routers/photos/update/index.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import exifReader from "exif-reader";
|
||||||
|
import { diff, sift } from "radash";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
import db from "@/db/db";
|
||||||
|
import { photosTable } from "@/db/schema/photo";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export async function update(): Promise<string[]> {
|
||||||
|
const photos = await db.select().from(photosTable);
|
||||||
|
const currentSources = photos.map((photo) => photo.src);
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: `https://fly.storage.tigris.dev`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listObjCmd = new ListObjectsV2Command({
|
||||||
|
Bucket: "joemonk-photos"
|
||||||
|
});
|
||||||
|
|
||||||
|
const s3Res = await s3Client.send(listObjCmd);
|
||||||
|
|
||||||
|
if (!s3Res.Contents) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "GATEWAY_TIMEOUT",
|
||||||
|
message: "Could not get contents from Tigris"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const s3Photos = sift(s3Res.Contents.map((obj) => {
|
||||||
|
if (!obj.Key?.endsWith('/')) {
|
||||||
|
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newPhotos = diff(s3Photos, currentSources);
|
||||||
|
|
||||||
|
if (newPhotos.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageData = newPhotos.map(async (fileName: string) => {
|
||||||
|
const getImageCmd = new GetObjectCommand({
|
||||||
|
Bucket: "joemonk-photos",
|
||||||
|
Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "")
|
||||||
|
});
|
||||||
|
const imgRes = await s3Client.send(getImageCmd);
|
||||||
|
const image = await imgRes.Body?.transformToByteArray();
|
||||||
|
|
||||||
|
const { width, height, exif } = await sharp(image).metadata();
|
||||||
|
const blur = await sharp(image)
|
||||||
|
.resize({ width: 12, height: 12, fit: 'inside' })
|
||||||
|
.toBuffer();
|
||||||
|
const exifData = exif ? exifReader(exif) : undefined;
|
||||||
|
|
||||||
|
const photo: typeof photosTable.$inferInsert = {
|
||||||
|
src: fileName,
|
||||||
|
width: width ?? 10,
|
||||||
|
height: height ?? 10,
|
||||||
|
blur: `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`,
|
||||||
|
camera: exifData?.Image?.Model ?? null,
|
||||||
|
|
||||||
|
exposureBiasValue: exifData?.Photo?.ExposureBiasValue ?? null,
|
||||||
|
fNumber: exifData?.Photo?.FNumber ?? null,
|
||||||
|
isoSpeedRatings: exifData?.Photo?.ISOSpeedRatings ?? null,
|
||||||
|
focalLength: exifData?.Photo?.FocalLength ?? null,
|
||||||
|
dateTimeOriginal: exifData?.Photo?.DateTimeOriginal ?? null,
|
||||||
|
lensModel: exifData?.Photo?.LensModel ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return photo;
|
||||||
|
});
|
||||||
|
|
||||||
|
const images = await Promise.all(imageData);
|
||||||
|
|
||||||
|
await db.insert(photosTable).values(images);
|
||||||
|
|
||||||
|
return newPhotos;
|
||||||
|
};
|
||||||
14
src/trpc/server.ts
Normal file
14
src/trpc/server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'server-only'; // <-- ensure this file cannot be imported from the client
|
||||||
|
import { createHydrationHelpers } from '@trpc/react-query/rsc';
|
||||||
|
import { cache } from 'react';
|
||||||
|
import { createCallerFactory, createTRPCContext } from './init';
|
||||||
|
import { makeQueryClient } from './query-client';
|
||||||
|
import { appRouter } from './routers/_app';
|
||||||
|
// IMPORTANT: Create a stable getter for the query client that
|
||||||
|
// will return the same client during the same request.
|
||||||
|
export const getQueryClient = cache(makeQueryClient);
|
||||||
|
const caller = createCallerFactory(appRouter)(createTRPCContext);
|
||||||
|
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
|
||||||
|
caller,
|
||||||
|
getQueryClient,
|
||||||
|
);
|
||||||
@@ -27,7 +27,19 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
],
|
||||||
|
"@/control/*": [
|
||||||
|
"./src/games/lib/control/*"
|
||||||
|
],
|
||||||
|
"@/core/*": [
|
||||||
|
"./src/games/lib/core/*"
|
||||||
|
],
|
||||||
|
"@/utils/*": [
|
||||||
|
"./src/games/lib/utils/*"
|
||||||
|
],
|
||||||
|
"@/visual/*": [
|
||||||
|
"./src/games/lib/visual/*"
|
||||||
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Reference in New Issue
Block a user