before react rewrite 💀
This commit is contained in:
parent
10543b5e92
commit
cc94580bbe
|
@ -0,0 +1,134 @@
|
|||
packages/server/prisma/dev.db
|
||||
packages/client/public/**/*.css
|
||||
packages/client/public/**/*.js
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "sc07-canvas",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"workspaces": [
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
"packages/lib"
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev:server": "cd packages/server && npm run dev",
|
||||
"dev:client": "cd packages/client && npm run dev"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@tsconfig/recommended": "^1.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quixo3/prisma-session-store": "^3.1.13"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
var gulp = require("gulp");
|
||||
var ts = require("gulp-typescript");
|
||||
const webpack = require("webpack-stream");
|
||||
const sass = require("gulp-sass")(require("sass"));
|
||||
|
||||
gulp.task("css", function () {
|
||||
return gulp
|
||||
.src("src/**/*.scss")
|
||||
.pipe(sass().on("error", sass.logError))
|
||||
.pipe(gulp.dest("./public/css"));
|
||||
});
|
||||
|
||||
gulp.task("js", function () {
|
||||
return gulp
|
||||
.src("src/**/*.ts")
|
||||
.pipe(webpack(require("./webpack.config.js")))
|
||||
.pipe(gulp.dest("public"));
|
||||
});
|
||||
|
||||
gulp.task(
|
||||
"watch",
|
||||
gulp.series("js", "css", function () {
|
||||
gulp.watch("src/**/*.ts", gulp.series("js"));
|
||||
gulp.watch("src/**/*.scss", gulp.series("css"));
|
||||
})
|
||||
);
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@sc07-canvas/client",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "gulp watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sc07-canvas/lib": "^1.0.0",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-cli": "^2.3.0",
|
||||
"gulp-typescript": "^2.12.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"ts-loader": "^9.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.22.10",
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"babel-loader": "^9.1.3",
|
||||
"gulp-sass": "^5.1.0",
|
||||
"sass": "^1.65.1",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-stream": "^7.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
<title>Document</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div></div>
|
||||
<div class="spacer"></div>
|
||||
<div>
|
||||
<div class="user-card">
|
||||
<div class="user-card--overview">
|
||||
<div class="user-name"></div>
|
||||
<div class="user-instance"></div>
|
||||
</div>
|
||||
<img src="#" alt="User Avatar" class="user-avatar" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="board-zoom">
|
||||
<div id="board-move" style="width: 100px; height: 100px">
|
||||
<canvas
|
||||
id="board"
|
||||
width="1000"
|
||||
height="1000"
|
||||
class="pixelate"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div id="cursor" style="display: none"></div>
|
||||
|
||||
<div id="pallete">
|
||||
<div id="canvas-meta">
|
||||
<!-- canvas is in charge of the metabox -->
|
||||
<span>
|
||||
Pixels:
|
||||
<span class="canvas-meta--pixels"></span>
|
||||
</span>
|
||||
<span>
|
||||
Users Online:
|
||||
<span class="canvas-meta--online"></span>
|
||||
</span>
|
||||
<button id="test">test button</button>
|
||||
</div>
|
||||
|
||||
<div class="pallete-colors"></div>
|
||||
<div class="pallete-user-overlay">
|
||||
you are not logged in
|
||||
<a href="/api/login" class="user-login">login</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,11 @@
|
|||
import "./lib/net";
|
||||
import Pallete from "./lib/pallete";
|
||||
import Canvas from "./lib/canvas";
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
const cursor = document.getElementById("cursor");
|
||||
cursor!.style.transform = `translate(${e.pageX}px, ${e.pageY}px)`;
|
||||
});
|
||||
|
||||
Canvas.setup();
|
||||
Canvas.draw();
|
|
@ -0,0 +1,32 @@
|
|||
import { SUserPacket, AuthSession } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
const $puseroverlay = document.querySelector(
|
||||
"#pallete .pallete-user-overlay"
|
||||
)! as HTMLDivElement;
|
||||
const $usercard = document.querySelector(".user-card")! as HTMLDivElement;
|
||||
const $usercard_name = $usercard.querySelector(".user-name")! as HTMLDivElement;
|
||||
const $usercard_instance = $usercard.querySelector(
|
||||
".user-instance"
|
||||
)! as HTMLDivElement;
|
||||
const $usercard_avatar = $usercard.querySelector(
|
||||
"img.user-avatar"
|
||||
)! as HTMLImageElement;
|
||||
|
||||
class Auth {
|
||||
private user: AuthSession | undefined;
|
||||
|
||||
login() {
|
||||
window.location.href = "/api/login";
|
||||
}
|
||||
|
||||
handleAuth(data: SUserPacket) {
|
||||
this.user = data.user;
|
||||
$puseroverlay.style.display = "none";
|
||||
|
||||
$usercard_name.innerText = data.user.user.username;
|
||||
$usercard_instance.innerHTML = data.user.service.instance.hostname;
|
||||
$usercard_avatar.setAttribute("src", data.user.user.profile);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Auth();
|
|
@ -0,0 +1,414 @@
|
|||
import pallete from "./pallete";
|
||||
import Network from "./net";
|
||||
import {
|
||||
CPixelPacket,
|
||||
SCanvasPacket,
|
||||
SPixelPacket,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
|
||||
const $moveContainer = document.querySelector("main")!;
|
||||
const $canvas = document.getElementById("board")! as HTMLCanvasElement;
|
||||
const $zoom = document.getElementById("board-zoom")!;
|
||||
const $move = document.getElementById("board-move")!;
|
||||
const $container = document.querySelector("main")!;
|
||||
|
||||
const $metabox = document.getElementById("canvas-meta")! as HTMLDivElement;
|
||||
const $metabox_pixels = $metabox.querySelector(
|
||||
".canvas-meta--pixels"
|
||||
) as HTMLSpanElement;
|
||||
const $metabox_online = $metabox.querySelector(
|
||||
".canvas-meta--online"
|
||||
) as HTMLSpanElement;
|
||||
|
||||
const $testbtn = document.getElementById("test")! as HTMLButtonElement;
|
||||
|
||||
export type CanvasConfig = {
|
||||
size: [number, number];
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
const ZOOM_SPEED = 0.1;
|
||||
|
||||
class Canvas {
|
||||
private config: CanvasConfig = { size: [0, 0], zoom: 1 };
|
||||
|
||||
/**
|
||||
* Last pixel place date
|
||||
*
|
||||
* TODO: Support pixel stacking
|
||||
*/
|
||||
private lastPlace: Date | undefined;
|
||||
private zoom = 1;
|
||||
private move = { x: 0, y: 0 };
|
||||
private cursor = { x: -1, y: -1 };
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
// private pixels: {
|
||||
// x: number;
|
||||
// y: number;
|
||||
// color: number;
|
||||
// type: "full" | "pending";
|
||||
// }[] = [];
|
||||
private pixels: {
|
||||
[x_y: string]: { color: number; type: "full" | "pending" };
|
||||
} = {};
|
||||
|
||||
private mouseDown = false;
|
||||
private dragOrigin = { x: 0, y: 0 };
|
||||
private downTime: number | undefined;
|
||||
|
||||
load(config: CanvasConfig) {
|
||||
this.config = config;
|
||||
$canvas.setAttribute("width", config.size[0] + "");
|
||||
$canvas.setAttribute("height", config.size[1] + "");
|
||||
this.setZoom(config.zoom);
|
||||
}
|
||||
|
||||
setZoom(newzoom: number, mouse?: { x: number; y: number }) {
|
||||
if (newzoom < 0.5) newzoom = 0.5;
|
||||
const oldzoom = this.zoom;
|
||||
this.zoom = newzoom;
|
||||
|
||||
$zoom.style.transform = `scale(${newzoom})`;
|
||||
|
||||
if (mouse) {
|
||||
const dx = mouse.x - $container.clientWidth / 2;
|
||||
const dy = mouse.y - $container.clientHeight / 2;
|
||||
this.move.x -= dx / oldzoom;
|
||||
this.move.x += dx / newzoom;
|
||||
this.move.y -= dy / oldzoom;
|
||||
this.move.y += dy / newzoom;
|
||||
$move.style.transform = `translate(${this.move.x}px, ${this.move.y}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.ctx = $canvas.getContext("2d");
|
||||
|
||||
let pinching = false;
|
||||
let pinchInit = 0;
|
||||
|
||||
$testbtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
$canvas.classList.toggle("pixelate");
|
||||
});
|
||||
|
||||
window.addEventListener("wheel", (e) => {
|
||||
const oldzoom = this.zoom;
|
||||
let newzoom = (this.zoom += ZOOM_SPEED * (e.deltaY > 0 ? -1 : 1));
|
||||
this.setZoom(newzoom, {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
});
|
||||
|
||||
const handleMouseStart = (e: { pageX: number; pageY: number }) => {
|
||||
this.mouseDown = true;
|
||||
this.dragOrigin = { x: e.pageX, y: e.pageY };
|
||||
this.downTime = Date.now();
|
||||
};
|
||||
|
||||
$moveContainer.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.changedTouches.length === 2) {
|
||||
pinching = true;
|
||||
pinchInit = Math.hypot(
|
||||
e.touches[0].pageX - e.touches[1].pageX,
|
||||
e.touches[0].pageY - e.touches[1].pageY
|
||||
);
|
||||
} else {
|
||||
handleMouseStart(e.touches[0]);
|
||||
}
|
||||
|
||||
$testbtn.innerText = e.changedTouches.length + "";
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
$moveContainer.addEventListener("mousedown", handleMouseStart);
|
||||
|
||||
const update = (x: number, y: number, update: boolean) => {
|
||||
const deltaX = (x - this.dragOrigin.x) / this.zoom;
|
||||
const deltaY = (y - this.dragOrigin.y) / this.zoom;
|
||||
const newX = this.move.x + deltaX;
|
||||
const newY = this.move.y + deltaY;
|
||||
if (update) {
|
||||
this.move.x += deltaX;
|
||||
this.move.y += deltaY;
|
||||
}
|
||||
$move.style.transform = `translate(${newX}px, ${newY}px)`;
|
||||
};
|
||||
|
||||
const handleMouseEnd = (e: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}) => {
|
||||
if (!this.mouseDown) return;
|
||||
|
||||
this.mouseDown = false;
|
||||
|
||||
const downDelta = Date.now() - this.downTime!;
|
||||
const delta = [
|
||||
Math.abs(this.dragOrigin.x - e.clientX),
|
||||
Math.abs(this.dragOrigin.y - e.clientY),
|
||||
];
|
||||
if (downDelta < 500) {
|
||||
// mouse was down for less than 500ms
|
||||
|
||||
if (delta[0] < 5 && delta[1] < 5) {
|
||||
const [x, y] = this.screenToPos(e.clientX, e.clientY);
|
||||
this.place(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
update(e.pageX, e.pageY, true);
|
||||
};
|
||||
|
||||
window.addEventListener("touchend", (e) => {
|
||||
if (pinching) {
|
||||
console.log("pinching end");
|
||||
pinching = false;
|
||||
} else {
|
||||
handleMouseEnd(e.changedTouches[0]);
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", (e) => {
|
||||
if (pinching) {
|
||||
pinching = false;
|
||||
} else {
|
||||
handleMouseEnd(e);
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseMove = (e: {
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => {
|
||||
if (this.mouseDown) {
|
||||
update(e.pageX, e.pageY, false);
|
||||
} else {
|
||||
const canvasRect = $canvas.getBoundingClientRect();
|
||||
if (
|
||||
canvasRect.left <= e.pageX &&
|
||||
canvasRect.right >= e.pageX &&
|
||||
canvasRect.top <= e.pageY &&
|
||||
canvasRect.bottom >= e.pageY
|
||||
) {
|
||||
const [x, y] = this.screenToPos(e.clientX, e.clientY);
|
||||
this.cursor.x = x;
|
||||
this.cursor.y = y;
|
||||
} else {
|
||||
this.cursor.x = -1;
|
||||
this.cursor.y = -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (pinching) {
|
||||
let initDiff = Math.hypot(
|
||||
e.touches[0].pageX - e.touches[1].pageX,
|
||||
e.touches[0].pageY - e.touches[1].pageY
|
||||
);
|
||||
let diff = (pinchInit - initDiff) / (10 / this.zoom);
|
||||
|
||||
let newzoom = (this.zoom += ZOOM_SPEED * -diff);
|
||||
this.setZoom(newzoom, {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
});
|
||||
|
||||
pinchInit = initDiff;
|
||||
} else {
|
||||
handleMouseMove(e.touches[0]);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
$canvas.addEventListener("touchmove", (e) => {
|
||||
if (!pinching) {
|
||||
const [x, y] = this.screenToPos(
|
||||
e.touches[0].clientX,
|
||||
e.touches[0].clientY
|
||||
);
|
||||
this.cursor.x = x;
|
||||
this.cursor.y = y;
|
||||
}
|
||||
});
|
||||
$canvas.addEventListener("mousemove", (e) => {
|
||||
const [x, y] = this.screenToPos(e.clientX, e.clientY);
|
||||
this.cursor.x = x;
|
||||
this.cursor.y = y;
|
||||
|
||||
// window.requestAnimationFrame(() => this.draw());
|
||||
});
|
||||
}
|
||||
|
||||
screenToPos(x: number, y: number) {
|
||||
const rect = $canvas.getBoundingClientRect();
|
||||
const scale = [$canvas.width / rect.width, $canvas.height / rect.height];
|
||||
return [x - rect.left, y - rect.top]
|
||||
.map((v, i) => v * scale[i])
|
||||
.map((v) => v >> 0);
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.ctx) {
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
return;
|
||||
}
|
||||
|
||||
/* @ts-ignore */
|
||||
this.ctx.mozImageSmoothingEnabled =
|
||||
/* @ts-ignore */
|
||||
this.ctx.webkitImageSmoothingEnabled =
|
||||
/* @ts-ignore */
|
||||
this.ctx.msImageSmoothingEnabled =
|
||||
this.ctx.imageSmoothingEnabled =
|
||||
false;
|
||||
|
||||
this.ctx.canvas.width = window.innerWidth;
|
||||
this.ctx.canvas.height = window.innerHeight;
|
||||
|
||||
const bezier = (n: number) => n * n * (3 - 2 * n);
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
this.ctx.fillStyle = "#fff";
|
||||
this.ctx.fillRect(0, 0, this.config.size[0], this.config.size[1]);
|
||||
|
||||
for (const [x_y, pixel] of Object.entries(this.pixels)) {
|
||||
const [x, y] = x_y.split("_").map((a) => parseInt(a));
|
||||
|
||||
this.ctx.globalAlpha = pixel.type === "full" ? 1 : 0.5;
|
||||
this.ctx.fillStyle =
|
||||
pixel.color > -1
|
||||
? "#" + pallete.getPalleteColor(pixel.color)!.hex
|
||||
: "transparent";
|
||||
this.ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
if (pallete.getColor() && this.cursor.x > -1 && this.cursor.y > -1) {
|
||||
let t = ((Date.now() / 100) % 10) / 10;
|
||||
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
|
||||
this.ctx.fillStyle = "#" + pallete.getColor()!.hex;
|
||||
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
|
||||
}
|
||||
|
||||
this.renderMetabox();
|
||||
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
|
||||
/**
|
||||
* update canvas metabox with latest information
|
||||
*
|
||||
* executed by #draw()
|
||||
*/
|
||||
renderMetabox() {
|
||||
if (this.lastPlace) {
|
||||
let cooldown =
|
||||
this.lastPlace.getTime() +
|
||||
pallete.getPixelCooldown() -
|
||||
new Date().getTime();
|
||||
$metabox_pixels.innerText = cooldown > 0 ? cooldown / 1000 + "s" : "none";
|
||||
} else {
|
||||
$metabox_pixels.innerText = "no cooldown";
|
||||
}
|
||||
|
||||
$metabox_online.innerText = Network.getOnline() + "";
|
||||
}
|
||||
|
||||
place(x: number, y: number) {
|
||||
if (!pallete.getColor()) return;
|
||||
|
||||
if (this.lastPlace) {
|
||||
if (
|
||||
this.lastPlace.getTime() + pallete.getPixelCooldown() >
|
||||
new Date().getTime()
|
||||
) {
|
||||
console.log("cannot place, cooldown in place");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastPlace = new Date();
|
||||
|
||||
const color = pallete.getColor()!;
|
||||
|
||||
// this.pixels.push({
|
||||
// x,
|
||||
// y,
|
||||
// color: color.id,
|
||||
// type: "pending",
|
||||
// });
|
||||
this.pixels[x + "_" + y] = {
|
||||
color: color.id,
|
||||
type: "pending",
|
||||
};
|
||||
|
||||
Network.sendAck<CPixelPacket, SPixelPacket>("place", {
|
||||
x,
|
||||
y,
|
||||
color: color.id,
|
||||
}).then((ack) => {
|
||||
// remove pending pixel at coord
|
||||
// can remove regardless of success or failure, as success places pixel at coord from server
|
||||
// this.pixels.splice(
|
||||
// this.pixels.indexOf(
|
||||
// this.pixels.find(
|
||||
// (pxl) =>
|
||||
// pxl.x === x &&
|
||||
// pxl.y === y &&
|
||||
// pxl.color === color.id &&
|
||||
// pxl.type === "pending"
|
||||
// )!
|
||||
// ),
|
||||
// 1
|
||||
// );
|
||||
|
||||
if (ack.success) {
|
||||
this.handlePixel(ack.data);
|
||||
} else {
|
||||
// TODO: handle undo pixel
|
||||
alert("Error: " + ack.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handlePixel({ x, y, color, ...rest }: SPixelPacket) {
|
||||
// this.pixels.push({
|
||||
// x,
|
||||
// y,
|
||||
// color,
|
||||
// type: "full",
|
||||
// });
|
||||
this.pixels[x + "_" + y] = {
|
||||
color,
|
||||
type: "full",
|
||||
};
|
||||
}
|
||||
|
||||
handleBatch(batch: SCanvasPacket) {
|
||||
batch.pixels.forEach((hex, index) => {
|
||||
const x = index / this.config.size[0];
|
||||
const y = index % this.config.size[0];
|
||||
const color = pallete.getPalleteFromHex(hex);
|
||||
|
||||
this.pixels[x + "_" + y] = {
|
||||
color: color ? color.id : -1,
|
||||
type: "full",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Canvas();
|
|
@ -0,0 +1,74 @@
|
|||
import io from "socket.io-client";
|
||||
import Pallete from "./pallete";
|
||||
import Canvas from "./canvas";
|
||||
import {
|
||||
CPixelPacket,
|
||||
ClientPacket,
|
||||
PacketAck,
|
||||
SCanvasPacket,
|
||||
SPixelPacket,
|
||||
SUserPacket,
|
||||
ServerPacket,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
import Auth from "./auth";
|
||||
|
||||
class Network {
|
||||
private socket;
|
||||
private online_count = 0;
|
||||
|
||||
constructor() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on("config", (config) => {
|
||||
Pallete.load(config.pallete);
|
||||
Canvas.load(config.canvas);
|
||||
});
|
||||
|
||||
this.socket.on("pixel", (data: SPixelPacket) => {
|
||||
Canvas.handlePixel(data);
|
||||
});
|
||||
|
||||
this.socket.on("user", (data: SUserPacket) => {
|
||||
Auth.handleAuth(data);
|
||||
});
|
||||
|
||||
this.socket.on("canvas", (data: SCanvasPacket) => {
|
||||
Canvas.handleBatch(data);
|
||||
});
|
||||
|
||||
this.socket.on("online", (data: { count: number }) => {
|
||||
this.online_count = data.count;
|
||||
});
|
||||
}
|
||||
|
||||
send<T extends ClientPacket>(
|
||||
type: T["type"],
|
||||
data: Omit<T, "type" | "_direction">
|
||||
) {
|
||||
// @ts-ignore
|
||||
data._direction = "client->server";
|
||||
|
||||
this.socket.emit(type, data);
|
||||
}
|
||||
|
||||
sendAck<O extends ClientPacket, I extends ServerPacket>(
|
||||
type: O["type"],
|
||||
data: Omit<O, "type" | "_direction">
|
||||
) {
|
||||
return new Promise<PacketAck<I>>((res) => {
|
||||
this.socket.emit(type, data, (data: PacketAck<I>) => {
|
||||
res(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online user count
|
||||
* @returns online users count
|
||||
*/
|
||||
getOnline() {
|
||||
return this.online_count;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Network();
|
|
@ -0,0 +1,80 @@
|
|||
import { SUserPacket } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
export type PalleteColor = {
|
||||
id: number;
|
||||
hex: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const $pallete = document.getElementById("pallete")!;
|
||||
const $pcolors = $pallete.querySelector(".pallete-colors")!;
|
||||
const $puseroverlay = $pallete.querySelector(
|
||||
".pallete-user-overlay"
|
||||
)! as HTMLDivElement;
|
||||
const $cursor = document.getElementById("cursor")!;
|
||||
|
||||
class Pallete {
|
||||
private pallete: PalleteColor[] = [];
|
||||
private active: PalleteColor | undefined;
|
||||
private pixel_cooldown = 0;
|
||||
|
||||
load({
|
||||
colors,
|
||||
pixel_cooldown,
|
||||
}: {
|
||||
colors: PalleteColor[];
|
||||
pixel_cooldown: number;
|
||||
}) {
|
||||
$pcolors.innerHTML = "";
|
||||
|
||||
this.pallete = colors;
|
||||
this.pixel_cooldown = pixel_cooldown;
|
||||
|
||||
colors.forEach((color) => {
|
||||
const $color = document.createElement("a");
|
||||
$color.href = "#";
|
||||
$color.classList.add("pallete-color");
|
||||
$color.style.backgroundColor = "#" + color.hex;
|
||||
$color.addEventListener("click", () => {
|
||||
this.pick(color);
|
||||
});
|
||||
$pcolors.append($color);
|
||||
});
|
||||
|
||||
this.active = undefined;
|
||||
}
|
||||
|
||||
pick(color?: PalleteColor) {
|
||||
this.active = color;
|
||||
|
||||
$cursor.style.display = color ? "block" : "none";
|
||||
|
||||
if (color) $cursor.style.backgroundColor = "#" + color.hex;
|
||||
}
|
||||
|
||||
getColor() {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getPallete() {
|
||||
return this.pallete;
|
||||
}
|
||||
|
||||
getPalleteColor(id: number) {
|
||||
return this.pallete.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
getPalleteFromHex(hex: string) {
|
||||
return this.pallete.find((p) => p.hex === hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pixel cooldown
|
||||
* @returns Pixel cooldown in ms
|
||||
*/
|
||||
getPixelCooldown() {
|
||||
return this.pixel_cooldown;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Pallete();
|
|
@ -0,0 +1,155 @@
|
|||
html,
|
||||
body {
|
||||
overscroll-behavior: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
z-index: 9999;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.user-card {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 5px 10px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
|
||||
&--overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
|
||||
span:first-of-type {
|
||||
font-size: 130%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
background-color: #aaa;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
#cursor {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 3px;
|
||||
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#pallete {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
|
||||
background-color: #fff;
|
||||
|
||||
.pallete-colors {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pallete-color {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pallete-user-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#canvas-meta {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
transform: translateY(-100%);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
z-index: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.pixelate {
|
||||
image-rendering: optimizeSpeed;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -o-crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
entry: "./src/index.ts",
|
||||
devtool: "inline-source-map",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: "ts-loader",
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".js"],
|
||||
},
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
path: path.resolve(__dirname, "public"),
|
||||
},
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@sc07-canvas/lib",
|
||||
"version": "1.0.0",
|
||||
"main": "./src/index.ts"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import * as net from "./net";
|
||||
|
||||
export { net };
|
|
@ -0,0 +1,60 @@
|
|||
export type CPixelPacket = ClientPacket & {
|
||||
type: "place";
|
||||
x: number;
|
||||
y: number;
|
||||
color: number;
|
||||
};
|
||||
|
||||
export type SCanvasPacket = ServerPacket & {
|
||||
type: "canvas";
|
||||
pixels: string[];
|
||||
};
|
||||
|
||||
export type SPixelPacket = ServerPacket & {
|
||||
type: "pixel";
|
||||
x: number;
|
||||
y: number;
|
||||
color: number;
|
||||
};
|
||||
|
||||
export type SUserPacket = ServerPacket & {
|
||||
type: "user";
|
||||
user: AuthSession;
|
||||
};
|
||||
|
||||
export type Packet = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
// server -> client
|
||||
export type ServerPacket = Packet & {
|
||||
_direction: "server->client";
|
||||
};
|
||||
|
||||
// client -> server
|
||||
export type ClientPacket = Packet & {
|
||||
_direction: "client->server";
|
||||
};
|
||||
|
||||
export type PacketAck<T = ServerPacket> =
|
||||
| {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
export type AuthSession = {
|
||||
service: {
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
instance: {
|
||||
hostname: string;
|
||||
};
|
||||
};
|
||||
user: {
|
||||
username: string;
|
||||
profile: string;
|
||||
};
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"execMap": {
|
||||
"ts": "ts-node"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@sc07-canvas/server",
|
||||
"version": "1.0.0",
|
||||
"main": "./build/index.js",
|
||||
"scripts": {
|
||||
"dev": "DOTENV_CONFIG_PATH=.env.local nodemon -r dotenv/config src/index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@tsconfig/recommended": "^1.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"dotenv": "^16.3.1",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.0.1",
|
||||
"prisma": "^5.3.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.3.1",
|
||||
"@sc07-canvas/lib": "^1.0.0",
|
||||
"connect-redis": "^7.1.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"redis": "^4.6.12",
|
||||
"socket.io": "^4.7.2"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"sub" TEXT NOT NULL PRIMARY KEY,
|
||||
"lastPixelTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PalleteColor" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"hex" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Pixel" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"x" INTEGER NOT NULL,
|
||||
"y" INTEGER NOT NULL,
|
||||
"color" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Pixel_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("sub") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Pixel_color_fkey" FOREIGN KEY ("color") REFERENCES "PalleteColor" ("hex") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PalleteColor_hex_key" ON "PalleteColor"("hex");
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
|
@ -0,0 +1,39 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
sub String @id
|
||||
lastPixelTime DateTime @default(now())
|
||||
|
||||
pixels Pixel[]
|
||||
}
|
||||
|
||||
model PalleteColor {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
hex String @unique
|
||||
|
||||
pixels Pixel[]
|
||||
}
|
||||
|
||||
model Pixel {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String
|
||||
x Int
|
||||
y Int
|
||||
color String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [sub])
|
||||
pallete PalleteColor @relation(fields: [color], references: [hex])
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { Router } from "express";
|
||||
import { prisma } from "./lib/prisma";
|
||||
|
||||
const app = Router();
|
||||
|
||||
const AUTH_ENDPOINT = "https://auth.fediverse.events";
|
||||
const AUTH_CLIENT = "canvas";
|
||||
const AUTH_SECRET = "secret";
|
||||
|
||||
app.get("/login", (req, res) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("service", "canvas");
|
||||
|
||||
res.redirect(AUTH_ENDPOINT + "/login?" + params);
|
||||
});
|
||||
|
||||
app.get("/callback", async (req, res) => {
|
||||
const { code } = req.query;
|
||||
|
||||
const who = await fetch(AUTH_ENDPOINT + "/api/auth/identify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${AUTH_CLIENT}:${AUTH_SECRET}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
}),
|
||||
}).then((a) => a.json());
|
||||
|
||||
const [username, hostname] = who.user.sub.split("@");
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: {
|
||||
sub: [username, hostname].join("@"),
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
sub: [username, hostname].join("@"),
|
||||
},
|
||||
});
|
||||
|
||||
req.session.user = {
|
||||
service: {
|
||||
...who.service,
|
||||
instance: {
|
||||
...who.service.instance,
|
||||
hostname,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
profile: who.user.profile,
|
||||
username,
|
||||
},
|
||||
};
|
||||
req.session.save();
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,190 @@
|
|||
import express from "express";
|
||||
import expressSession from "express-session";
|
||||
import http from "node:http";
|
||||
import { Server } from "socket.io";
|
||||
import {
|
||||
CPixelPacket,
|
||||
PacketAck,
|
||||
SCanvasPacket,
|
||||
SPixelPacket,
|
||||
SUserPacket,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
import APIRoutes from "./api";
|
||||
|
||||
// load declare module
|
||||
import "./types";
|
||||
import { prisma } from "./lib/prisma";
|
||||
import { PalleteColor, PrismaClient } from "@prisma/client";
|
||||
import { getRedis, client as redisClient } from "./lib/redis";
|
||||
import Canvas from "./lib/Canvas";
|
||||
import RedisStore from "connect-redis";
|
||||
|
||||
if (!process.env.PORT || isNaN(parseInt(process.env.PORT))) {
|
||||
console.log("PORT env is not a valid number");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getRedis().then(() => {
|
||||
console.log("redis connected");
|
||||
});
|
||||
|
||||
const session = expressSession({
|
||||
secret: "jagoprhaupihuaciohruearp8349jud",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new RedisStore({
|
||||
client: redisClient,
|
||||
prefix: "canvas_session:",
|
||||
}),
|
||||
});
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server<
|
||||
{
|
||||
place: (
|
||||
data: CPixelPacket,
|
||||
callback: (data: PacketAck<SPixelPacket>) => {}
|
||||
) => void;
|
||||
},
|
||||
{
|
||||
user: (user: SUserPacket) => void;
|
||||
config: (config: any) => void;
|
||||
pixel: (data: SPixelPacket) => void;
|
||||
canvas: (data: SCanvasPacket) => void;
|
||||
online: (data: { count: number }) => void;
|
||||
}
|
||||
>(server);
|
||||
|
||||
var PALLETE: PalleteColor[] = [];
|
||||
const PIXEL_TIMEOUT_MS = 1000;
|
||||
|
||||
prisma.palleteColor
|
||||
.findMany()
|
||||
.then((palleteColors) => {
|
||||
PALLETE = palleteColors;
|
||||
console.log(`Loaded ${palleteColors.length} pallete colors`);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
// on startup, cache current top-level pixels on redis
|
||||
// notify all other shards with pixel updates via redis
|
||||
|
||||
// cacheCanvasToRedis().then(() => {
|
||||
// console.log("canvas is now in redis");
|
||||
// });
|
||||
|
||||
setInterval(async () => {
|
||||
const sockets = await io.sockets.fetchSockets();
|
||||
for (const socket of sockets) {
|
||||
socket.emit("online", { count: sockets.length });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
io.engine.use(session);
|
||||
io.on("connection", (socket) => {
|
||||
const user = socket.request.session.user
|
||||
? {
|
||||
sub:
|
||||
socket.request.session.user.user.username +
|
||||
"@" +
|
||||
socket.request.session.user.service.instance.hostname,
|
||||
...socket.request.session.user,
|
||||
}
|
||||
: undefined;
|
||||
console.log("connection", socket.request.session.user);
|
||||
|
||||
if (socket.request.session.user)
|
||||
socket.emit("user", {
|
||||
type: "user",
|
||||
user: socket.request.session.user,
|
||||
_direction: "server->client",
|
||||
});
|
||||
|
||||
socket.emit("config", {
|
||||
pallete: {
|
||||
colors: PALLETE,
|
||||
pixel_cooldown: PIXEL_TIMEOUT_MS,
|
||||
},
|
||||
canvas: Canvas.getCanvasConfig(),
|
||||
});
|
||||
|
||||
Canvas.getPixelsArray().then((pixels) => {
|
||||
socket.emit("canvas", {
|
||||
_direction: "server->client",
|
||||
type: "canvas",
|
||||
pixels,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"place",
|
||||
async (
|
||||
{ x, y, color }: CPixelPacket,
|
||||
ack: (data: PacketAck<SPixelPacket>) => {}
|
||||
) => {
|
||||
if (!user) {
|
||||
ack({
|
||||
success: false,
|
||||
error: "no_user",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
|
||||
if (puser?.lastPixelTime) {
|
||||
if (
|
||||
puser.lastPixelTime.getTime() + PIXEL_TIMEOUT_MS >
|
||||
new Date().getTime()
|
||||
) {
|
||||
ack({
|
||||
success: false,
|
||||
error: "pixel_cooldown",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const palleteColor = await prisma.palleteColor.findFirst({
|
||||
where: {
|
||||
id: color,
|
||||
},
|
||||
});
|
||||
|
||||
if (!palleteColor) {
|
||||
ack({
|
||||
success: false,
|
||||
error: "pallete_color_invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Canvas.setPixel(user, x, y, palleteColor.hex);
|
||||
|
||||
ack({
|
||||
success: true,
|
||||
data: {
|
||||
type: "pixel",
|
||||
_direction: "server->client",
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
},
|
||||
});
|
||||
socket.broadcast.emit("pixel", {
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
type: "pixel",
|
||||
_direction: "server->client",
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.use(session);
|
||||
app.use(express.static("../client/public"));
|
||||
app.use("/api", APIRoutes);
|
||||
|
||||
server.listen(parseInt(process.env.PORT!), () => {
|
||||
console.log("Listening on " + process.env.PORT);
|
||||
});
|
|
@ -0,0 +1,124 @@
|
|||
import { prisma } from "./prisma";
|
||||
import { getRedis } from "./redis";
|
||||
|
||||
const redis_keys = {
|
||||
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
||||
canvas: () => `CANVAS:PIXELS`,
|
||||
};
|
||||
|
||||
class Canvas {
|
||||
private CANVAS_SIZE: [number, number];
|
||||
|
||||
constructor() {
|
||||
this.CANVAS_SIZE = [100, 100];
|
||||
}
|
||||
|
||||
getCanvasConfig() {
|
||||
return {
|
||||
size: this.CANVAS_SIZE,
|
||||
zoom: 7,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest database pixels -> Redis
|
||||
*/
|
||||
async pixelsToRedis() {
|
||||
const redis = await getRedis();
|
||||
|
||||
const key = redis_keys.pixelColor;
|
||||
|
||||
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
||||
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
||||
const pixel = await prisma.pixel.findFirst({
|
||||
where: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await redis.set(key(x, y), pixel?.color || "transparent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis pixels -> single Redis comma separated list of hex
|
||||
* @returns 1D array of pixel values
|
||||
*/
|
||||
async canvasToRedis() {
|
||||
const redis = await getRedis();
|
||||
|
||||
let pixels: string[] = [];
|
||||
|
||||
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
|
||||
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
|
||||
pixels.push(
|
||||
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
/**
|
||||
* force an update at a specific position
|
||||
*/
|
||||
async updateCanvasRedisAtPos(x: number, y: number) {
|
||||
const redis = await getRedis();
|
||||
|
||||
let pixels: string[] = ((await redis.get(redis_keys.canvas())) || "").split(
|
||||
","
|
||||
);
|
||||
|
||||
pixels[this.CANVAS_SIZE[0] * y + x] =
|
||||
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent";
|
||||
|
||||
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
|
||||
}
|
||||
|
||||
async getPixelsArray() {
|
||||
const redis = await getRedis();
|
||||
|
||||
if (await redis.exists(redis_keys.canvas())) {
|
||||
const cached = await redis.get(redis_keys.canvas());
|
||||
return cached!.split(",");
|
||||
}
|
||||
|
||||
return await this.canvasToRedis();
|
||||
}
|
||||
|
||||
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
|
||||
const redis = await getRedis();
|
||||
|
||||
await prisma.pixel.create({
|
||||
data: {
|
||||
userId: user.sub,
|
||||
color: hex,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { sub: user.sub },
|
||||
data: { lastPixelTime: new Date() },
|
||||
});
|
||||
|
||||
await redis.set(`CANVAS:PIXELS[${x},${y}]:COLOR`, hex);
|
||||
|
||||
// maybe only update specific element?
|
||||
// i don't think it needs to be awaited
|
||||
await this.updateCanvasRedisAtPos(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Canvas();
|
|
@ -0,0 +1,3 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
|
@ -0,0 +1,14 @@
|
|||
import { RedisClientType } from "@redis/client";
|
||||
import { createClient } from "redis";
|
||||
|
||||
let isConnected = false;
|
||||
export const client = createClient();
|
||||
|
||||
export const getRedis = async () => {
|
||||
if (!isConnected) {
|
||||
await client.connect();
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
return client;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import type { IncomingMessage } from "http";
|
||||
import type { Session, SessionData } from "express-session";
|
||||
import type { Socket } from "socket.io";
|
||||
import session from "express-session";
|
||||
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user: AuthSession;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
cookieHolder?: string;
|
||||
session: Session & Partial<session.SessionData>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "build"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue