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