client-next (react rewrite)
This commit is contained in:
parent
cc94580bbe
commit
2e469e39a9
|
@ -1,6 +1,8 @@
|
||||||
packages/server/prisma/dev.db
|
packages/server/prisma/dev.db
|
||||||
packages/client/public/**/*.css
|
packages/client/public/**/*.css
|
||||||
packages/client/public/**/*.js
|
packages/client/public/**/*.js
|
||||||
|
packages/client-next/public/**/*.css
|
||||||
|
packages/client-next/public/**/*.js
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/client",
|
"packages/client-next",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
"packages/lib"
|
"packages/lib"
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/plugin-syntax-dynamic-import",
|
||||||
|
"@babel/plugin-proposal-class-properties"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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/style.scss")
|
||||||
|
.pipe(sass().on("error", sass.logError))
|
||||||
|
.pipe(gulp.dest("./public/css"));
|
||||||
|
});
|
||||||
|
|
||||||
|
gulp.task("js", function () {
|
||||||
|
return gulp
|
||||||
|
.src("src/**/*.tsx?")
|
||||||
|
.pipe(webpack(require("./webpack.config.js")))
|
||||||
|
.pipe(gulp.dest("public"));
|
||||||
|
});
|
||||||
|
|
||||||
|
gulp.task(
|
||||||
|
"watch",
|
||||||
|
gulp.series("js", "css", function () {
|
||||||
|
gulp.watch(["src/**/*.ts", "src/**/*.tsx"], gulp.series("js"));
|
||||||
|
gulp.watch("src/**/*.scss", gulp.series("css"));
|
||||||
|
})
|
||||||
|
);
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
"@sc07-canvas/lib": "^1.0.0",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-zoom-pan-pinch": "^3.4.1",
|
||||||
|
"socket.io-client": "^4.7.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.23.9",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||||
|
"@babel/preset-env": "^7.23.9",
|
||||||
|
"@babel/preset-react": "^7.23.3",
|
||||||
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@types/socket.io-client": "^3.0.0",
|
||||||
|
"babel-loader": "^9.1.3",
|
||||||
|
"css-loader": "^6.9.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-react-app": "^7.0.1",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"gulp": "^4.0.2",
|
||||||
|
"gulp-cli": "^2.3.0",
|
||||||
|
"gulp-typescript": "^6.0.0-alpha.1",
|
||||||
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"style-loader": "^3.3.4",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"webpack": "^5.90.0",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-dev-server": "^4.15.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>webpack-for-react</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { AppContext } from "../contexts/AppContext";
|
||||||
|
import { CanvasWrapper } from "./CanvasWrapper";
|
||||||
|
import { Pallete } from "./Pallete";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<AppContext>
|
||||||
|
<Header />
|
||||||
|
<CanvasWrapper />
|
||||||
|
<Pallete />
|
||||||
|
</AppContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React, { createRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
TransformComponent,
|
||||||
|
TransformWrapper,
|
||||||
|
useControls,
|
||||||
|
useTransformEffect,
|
||||||
|
} from "react-zoom-pan-pinch";
|
||||||
|
import { Canvas } from "../lib/canvas";
|
||||||
|
import { useAppContext } from "../contexts/AppContext";
|
||||||
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
|
export const CanvasWrapper = () => {
|
||||||
|
// to prevent safari from blurring things, use the zoom css property
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<TransformWrapper
|
||||||
|
centerOnInit
|
||||||
|
limitToBounds={false}
|
||||||
|
centerZoomedOut={false}
|
||||||
|
minScale={0.5}
|
||||||
|
maxScale={50}
|
||||||
|
wheel={{
|
||||||
|
step: 0.05,
|
||||||
|
smoothStep: 0.05,
|
||||||
|
}}
|
||||||
|
initialScale={5}
|
||||||
|
panning={{
|
||||||
|
velocityDisabled: true,
|
||||||
|
}}
|
||||||
|
doubleClick={{
|
||||||
|
disabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TransformComponent wrapperStyle={{ width: "100%", height: "100%" }}>
|
||||||
|
<CanvasInner />
|
||||||
|
</TransformComponent>
|
||||||
|
</TransformWrapper>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CanvasInner = () => {
|
||||||
|
const canvasRef = createRef<HTMLCanvasElement>();
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const { centerView } = useControls();
|
||||||
|
|
||||||
|
useTransformEffect(
|
||||||
|
throttle(({ state, instance }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("x", state.positionX + "");
|
||||||
|
params.set("y", state.positionY + "");
|
||||||
|
params.set("zoom", state.scale + "");
|
||||||
|
window.location.hash = params.toString();
|
||||||
|
}, 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.canvas || !canvasRef.current) return;
|
||||||
|
const canvas = canvasRef.current!;
|
||||||
|
const canvasInstance = new Canvas(config, canvas);
|
||||||
|
centerView();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvasInstance.destroy();
|
||||||
|
};
|
||||||
|
}, [canvasRef, centerView, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
id="board"
|
||||||
|
width="1000"
|
||||||
|
height="1000"
|
||||||
|
className="pixelate"
|
||||||
|
ref={canvasRef}
|
||||||
|
></canvas>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const Header = () => {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div></div>
|
||||||
|
<div className="spacer"></div>
|
||||||
|
<div>
|
||||||
|
<div className="user-card">
|
||||||
|
<div className="user-card--overview">
|
||||||
|
<div className="user-name"></div>
|
||||||
|
<div className="user-instance"></div>
|
||||||
|
</div>
|
||||||
|
<img src="#" alt="User Avatar" className="user-avatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,78 @@
|
||||||
|
#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;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.pallete-color {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallete-color--deselect {
|
||||||
|
display: flex-inline;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #000;
|
||||||
|
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pallete-color {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: transform 0.25s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useAppContext } from "../contexts/AppContext";
|
||||||
|
import { Canvas } from "../lib/canvas";
|
||||||
|
import { IPalleteContext } from "../types";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
export const Pallete = () => {
|
||||||
|
const { config, user } = useAppContext();
|
||||||
|
const [pallete, setPallete] = useState<IPalleteContext>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Canvas.instance) return;
|
||||||
|
|
||||||
|
Canvas.instance.emit("pallete", pallete);
|
||||||
|
}, [pallete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="pallete">
|
||||||
|
<CanvasMeta />
|
||||||
|
|
||||||
|
<div className="pallete-colors">
|
||||||
|
<button
|
||||||
|
aria-label="Deselect Color"
|
||||||
|
className="pallete-color--deselect"
|
||||||
|
title="Deselect Color"
|
||||||
|
onClick={() => {
|
||||||
|
setPallete(({ color, ...pallete }) => {
|
||||||
|
return pallete;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
{config.pallete.colors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.id}
|
||||||
|
aria-label={color.name}
|
||||||
|
className={["pallete-color", color.id === pallete.color && "active"]
|
||||||
|
.filter((a) => a)
|
||||||
|
.join(" ")}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#" + color.hex,
|
||||||
|
}}
|
||||||
|
title={color.name}
|
||||||
|
onClick={() => {
|
||||||
|
setPallete((pallete) => {
|
||||||
|
return {
|
||||||
|
...pallete,
|
||||||
|
color: color.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<div className="pallete-user-overlay">
|
||||||
|
You are not logged in
|
||||||
|
<a href="/api/login" className="user-login">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CanvasMeta = () => {
|
||||||
|
return (
|
||||||
|
<div id="canvas-meta">
|
||||||
|
<span>
|
||||||
|
Pixels: <span>123</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Users Online: <span>321</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <div className="test-wrapper">{children}</div>;
|
||||||
|
};
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
import { ClientConfig, IAppContext, IPalleteContext } from "../types";
|
||||||
|
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||||
|
import { number } from "prop-types";
|
||||||
|
import Network from "../lib/network";
|
||||||
|
|
||||||
|
const appContext = createContext<IAppContext>({} as any);
|
||||||
|
|
||||||
|
export const useAppContext = () => useContext(appContext);
|
||||||
|
|
||||||
|
export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
|
const [config, setConfig] = useState<ClientConfig>(undefined as any);
|
||||||
|
const [auth, setAuth] = useState<AuthSession>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleConfig(config: ClientConfig) {
|
||||||
|
console.info("Server sent config", config);
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUser(user: AuthSession) {
|
||||||
|
setAuth(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Network.on("user", handleUser);
|
||||||
|
Network.on("config", handleConfig);
|
||||||
|
|
||||||
|
Network.socket.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Network.off("user", handleUser);
|
||||||
|
Network.off("config", handleConfig);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<appContext.Provider value={{ config, user: auth }}>
|
||||||
|
{config ? children : "Loading..."}
|
||||||
|
</appContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./components/App";
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<App />);
|
|
@ -0,0 +1,221 @@
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
import { ClientConfig, IPalleteContext, Pixel } from "../types";
|
||||||
|
import Network from "./network";
|
||||||
|
|
||||||
|
export class Canvas extends EventEmitter {
|
||||||
|
static instance: Canvas | undefined;
|
||||||
|
|
||||||
|
private _destroy = false;
|
||||||
|
private config: ClientConfig;
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
private cursor = { x: -1, y: -1 };
|
||||||
|
private pixels: {
|
||||||
|
[x_y: string]: { color: number; type: "full" | "pending" };
|
||||||
|
} = {};
|
||||||
|
private lastPlace: number | undefined;
|
||||||
|
|
||||||
|
constructor(config: ClientConfig, canvas: HTMLCanvasElement) {
|
||||||
|
super();
|
||||||
|
Canvas.instance = this;
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
canvas.width = config.canvas.size[0];
|
||||||
|
canvas.height = config.canvas.size[1];
|
||||||
|
|
||||||
|
canvas.addEventListener("mousemove", this.handleMouseMove.bind(this));
|
||||||
|
canvas.addEventListener("mouseup", this.handleMouseClick.bind(this));
|
||||||
|
canvas.addEventListener("mousedown", this.handleMouseDown.bind(this));
|
||||||
|
|
||||||
|
this.on("pallete", this.updatePallete.bind(this));
|
||||||
|
|
||||||
|
// Network.on("canvas", this.handleBatch.bind(this));
|
||||||
|
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
|
||||||
|
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._destroy = true;
|
||||||
|
|
||||||
|
this.canvas.removeEventListener(
|
||||||
|
"mousemove",
|
||||||
|
this.handleMouseMove.bind(this)
|
||||||
|
);
|
||||||
|
this.canvas.removeEventListener(
|
||||||
|
"mouseup",
|
||||||
|
this.handleMouseClick.bind(this)
|
||||||
|
);
|
||||||
|
this.canvas.removeEventListener(
|
||||||
|
"mousedown",
|
||||||
|
this.handleMouseDown.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
Network.off("canvas", this.handleBatch.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private downTime: number | undefined;
|
||||||
|
private dragOrigin: { x: number; y: number } = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
handleMouseClick(e: MouseEvent) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(e: MouseEvent) {
|
||||||
|
this.downTime = Date.now();
|
||||||
|
this.dragOrigin = { x: e.pageX, y: e.pageY };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(e: MouseEvent) {
|
||||||
|
const canvasRect = this.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBatch(pixels: string[]) {
|
||||||
|
pixels.forEach((hex, index) => {
|
||||||
|
const x = index / this.config.canvas.size[0];
|
||||||
|
const y = index % this.config.canvas.size[0];
|
||||||
|
const color = this.Pallete.getColorFromHex(hex);
|
||||||
|
|
||||||
|
this.pixels[x + "_" + y] = {
|
||||||
|
color: color ? color.id : -1,
|
||||||
|
type: "full",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePixel({ x, y, color, ...pixel }: Pixel) {
|
||||||
|
this.pixels[x + "_" + y] = {
|
||||||
|
color,
|
||||||
|
type: "full",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
palleteCtx: IPalleteContext = {};
|
||||||
|
Pallete = {
|
||||||
|
getColor: (colorId: number) => {
|
||||||
|
return this.config.pallete.colors.find((c) => c.id === colorId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedColor: () => {
|
||||||
|
if (!this.palleteCtx.color) return undefined;
|
||||||
|
|
||||||
|
return this.Pallete.getColor(this.palleteCtx.color);
|
||||||
|
},
|
||||||
|
|
||||||
|
getColorFromHex: (hex: string) => {
|
||||||
|
return this.config.pallete.colors.find((c) => c.hex === hex);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePallete(pallete: IPalleteContext) {
|
||||||
|
this.palleteCtx = pallete;
|
||||||
|
}
|
||||||
|
|
||||||
|
place(x: number, y: number) {
|
||||||
|
if (!this.Pallete.getSelectedColor()) return;
|
||||||
|
|
||||||
|
if (this.lastPlace) {
|
||||||
|
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
|
||||||
|
console.log("cannot place; cooldown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPlace = Date.now();
|
||||||
|
|
||||||
|
Network.socket
|
||||||
|
.emitWithAck("place", {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
color: this.Pallete.getSelectedColor()!.id,
|
||||||
|
})
|
||||||
|
.then((ack) => {
|
||||||
|
if (ack.success) {
|
||||||
|
this.handlePixel(ack.data);
|
||||||
|
} else {
|
||||||
|
// TODO: handle undo pixel
|
||||||
|
alert("error: " + ack.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
screenToPos(x: number, y: number) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const scale = [
|
||||||
|
this.canvas.width / rect.width,
|
||||||
|
this.canvas.height / rect.height,
|
||||||
|
];
|
||||||
|
return [x - rect.left, y - rect.top]
|
||||||
|
.map((v, i) => v * scale[i])
|
||||||
|
.map((v) => v >> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
const bezier = (n: number) => n * n * (3 - 2 * n);
|
||||||
|
|
||||||
|
this.ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
this.ctx.fillStyle = "#fff";
|
||||||
|
this.ctx.fillRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.config.canvas.size[0],
|
||||||
|
this.config.canvas.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
|
||||||
|
? "#" + this.Pallete.getColor(pixel.color)!.hex
|
||||||
|
: "transparent";
|
||||||
|
this.ctx.fillRect(x, y, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.palleteCtx.color && this.cursor.x > -1 && this.cursor.y > -1) {
|
||||||
|
const color = this.config.pallete.colors.find(
|
||||||
|
(c) => c.id === this.palleteCtx.color
|
||||||
|
);
|
||||||
|
|
||||||
|
let t = ((Date.now() / 100) % 10) / 10;
|
||||||
|
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
|
||||||
|
this.ctx.fillStyle = "#" + color!.hex;
|
||||||
|
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._destroy) window.requestAnimationFrame(() => this.draw());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Socket, io } from "socket.io-client";
|
||||||
|
import {
|
||||||
|
ClientConfig,
|
||||||
|
ClientToServerEvents,
|
||||||
|
ServerToClientEvents,
|
||||||
|
} from "../types";
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||||
|
|
||||||
|
export interface INetworkEvents {
|
||||||
|
user: (user: AuthSession) => void;
|
||||||
|
config: (user: ClientConfig) => void;
|
||||||
|
canvas: (pixels: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
|
||||||
|
Exclude<INetworkEvents, string | symbol>
|
||||||
|
>[Extract<K, keyof INetworkEvents>];
|
||||||
|
|
||||||
|
class Network extends EventEmitter<INetworkEvents> {
|
||||||
|
socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
|
||||||
|
autoConnect: false,
|
||||||
|
});
|
||||||
|
private online_count = 0;
|
||||||
|
private sentEvents: {
|
||||||
|
[key in keyof INetworkEvents]?: SentEventValue<key>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.socket.on("user", (user: AuthSession) => {
|
||||||
|
this.emit("user", user);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("config", (config) => {
|
||||||
|
this.emit("config", config);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("canvas", (pixels) => {
|
||||||
|
this._emit("canvas", pixels);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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("canvas", (data: SCanvasPacket) => {
|
||||||
|
// Canvas.handleBatch(data);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// this.socket.on("online", (data: { count: number }) => {
|
||||||
|
// this.online_count = data.count;
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _emit: typeof this.emit = (event, ...args) => {
|
||||||
|
this.sentEvents[event] = args;
|
||||||
|
return this.emit(event, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
waitFor<Ev extends keyof INetworkEvents & (string | symbol)>(
|
||||||
|
ev: Ev
|
||||||
|
): Promise<SentEventValue<Ev>> {
|
||||||
|
return new Promise((res) => {
|
||||||
|
if (this.sentEvents[ev]) return res(this.sentEvents[ev]!);
|
||||||
|
|
||||||
|
this.once(ev, (...data) => {
|
||||||
|
res(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get online user count
|
||||||
|
* @returns online users count
|
||||||
|
*/
|
||||||
|
getOnline() {
|
||||||
|
return this.online_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Network() as Network;
|
|
@ -0,0 +1,118 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@import "./components/Pallete.scss";
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { AuthSession, PacketAck } from "@sc07-canvas/lib/src/net";
|
||||||
|
|
||||||
|
// socket.io
|
||||||
|
|
||||||
|
export interface ServerToClientEvents {
|
||||||
|
canvas: (pixels: string[]) => void;
|
||||||
|
user: (user: AuthSession) => void;
|
||||||
|
config: (config: ClientConfig) => void;
|
||||||
|
pixel: (pixel: Pixel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientToServerEvents {
|
||||||
|
place: (pixel: Pixel, ack: (_: PacketAck<Pixel>) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// app context
|
||||||
|
|
||||||
|
export interface IAppContext {
|
||||||
|
config: ClientConfig;
|
||||||
|
user?: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPalleteContext {
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// other
|
||||||
|
|
||||||
|
export type Pixel = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PalleteColor = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
hex: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasConfig = {
|
||||||
|
size: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientConfig = {
|
||||||
|
pallete: {
|
||||||
|
colors: PalleteColor[];
|
||||||
|
pixel_cooldown: number;
|
||||||
|
};
|
||||||
|
canvas: CanvasConfig;
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": true,
|
||||||
|
"jsx": "react",
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
const webpack = require("webpack");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: "development",
|
||||||
|
entry: "./src/index.tsx",
|
||||||
|
output: {
|
||||||
|
filename: "bundle.js",
|
||||||
|
path: path.resolve(__dirname, "public"),
|
||||||
|
},
|
||||||
|
devtool: "inline-source-map",
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: "ts-loader",
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".tsx", ".ts", ".js"],
|
||||||
|
},
|
||||||
|
};
|
|
@ -50,7 +50,7 @@ const io = new Server<
|
||||||
user: (user: SUserPacket) => void;
|
user: (user: SUserPacket) => void;
|
||||||
config: (config: any) => void;
|
config: (config: any) => void;
|
||||||
pixel: (data: SPixelPacket) => void;
|
pixel: (data: SPixelPacket) => void;
|
||||||
canvas: (data: SCanvasPacket) => void;
|
canvas: (pixels: string[]) => void;
|
||||||
online: (data: { count: number }) => void;
|
online: (data: { count: number }) => void;
|
||||||
}
|
}
|
||||||
>(server);
|
>(server);
|
||||||
|
@ -109,11 +109,7 @@ io.on("connection", (socket) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Canvas.getPixelsArray().then((pixels) => {
|
Canvas.getPixelsArray().then((pixels) => {
|
||||||
socket.emit("canvas", {
|
socket.emit("canvas", pixels);
|
||||||
_direction: "server->client",
|
|
||||||
type: "canvas",
|
|
||||||
pixels,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(
|
socket.on(
|
||||||
|
@ -182,7 +178,7 @@ io.on("connection", (socket) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(session);
|
app.use(session);
|
||||||
app.use(express.static("../client/public"));
|
app.use(express.static("../client-next/public"));
|
||||||
app.use("/api", APIRoutes);
|
app.use("/api", APIRoutes);
|
||||||
|
|
||||||
server.listen(parseInt(process.env.PORT!), () => {
|
server.listen(parseInt(process.env.PORT!), () => {
|
||||||
|
|
Loading…
Reference in New Issue