Working multiplayer + brush-like strokes
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
export type CanvasState = {
|
||||
cursorP?: Cursor,
|
||||
cursorK?: Cursor
|
||||
strokes: Stroke[],
|
||||
}
|
||||
|
||||
export type Cursor = {
|
||||
export type Stroke = {
|
||||
points: Point[],
|
||||
color: string,
|
||||
width: number
|
||||
}
|
||||
|
||||
export type Point = {
|
||||
x: number,
|
||||
y: number,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cursor } from "../../model/canvas-state";
|
||||
import { Point, Stroke } from "../../model/canvas-state";
|
||||
import { ClientStateService } from "../state/state.client-service";
|
||||
|
||||
|
||||
@@ -6,72 +6,117 @@ import { ClientStateService } from "../state/state.client-service";
|
||||
export class ClientDrawService {
|
||||
private readonly canvasContext: CanvasRenderingContext2D
|
||||
|
||||
constructor(
|
||||
private currentColor
|
||||
|
||||
private currentWidth = 12;
|
||||
|
||||
private currentStroke?: number
|
||||
|
||||
constructor(
|
||||
private readonly stateService: ClientStateService,
|
||||
private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement
|
||||
private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement,
|
||||
private readonly colorPicker = document.getElementById('colorPicker')!,
|
||||
private readonly widthSlider = document.getElementById('widthSlider')!,
|
||||
private readonly widthValue = document.getElementById('widthValue')!,
|
||||
private readonly menuButton = document.getElementById('menuButton')!,
|
||||
private readonly drawer = document.getElementById('drawer')!,
|
||||
private readonly closeButton = document.getElementById('closeButton')!,
|
||||
) {
|
||||
this.resizeCanvas()
|
||||
this.setupCanvasEvents()
|
||||
window.addEventListener("resize", () => this.resizeCanvas());
|
||||
this.canvasContext = canvas.getContext("2d")!
|
||||
this.currentColor = colorPicker?.getAttribute('value') ?? "#eaafff"
|
||||
}
|
||||
|
||||
draw() {
|
||||
const cursors: Cursor[] = this.stateService.getCursors()
|
||||
const strokes: Stroke[] = this.stateService.getStrokes()
|
||||
|
||||
this.canvasContext.globalAlpha = 0.05;
|
||||
this.canvasContext.fillStyle = "white";
|
||||
this.canvasContext.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.canvasContext.globalAlpha = 1;
|
||||
|
||||
cursors
|
||||
.forEach(cursor => {
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.arc(cursor.x, cursor.y, 30, 0, Math.PI * 2);
|
||||
this.canvasContext.fillStyle = "#eaafff";
|
||||
this.canvasContext.fill();
|
||||
})
|
||||
strokes.forEach(stroke => {
|
||||
this.canvasContext.fillStyle = stroke.color;
|
||||
this.drawCurve(stroke.points, stroke.width)
|
||||
})
|
||||
|
||||
requestAnimationFrame(() => this.draw())
|
||||
}
|
||||
|
||||
private onMouseMove = (e: any) => {
|
||||
const cursor = this.stateService.get('cursorK')
|
||||
if (!cursor) {
|
||||
|
||||
private addPointToStroke = (e: any) => {
|
||||
if (this.currentStroke === undefined) {
|
||||
return
|
||||
}
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.stateService.set('cursorK', {
|
||||
this.stateService.addPoint(this.currentStroke, {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
})
|
||||
};
|
||||
|
||||
private onMouseDown = (e: any) => {
|
||||
private beginStroke = (e: any) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
this.stateService.set('cursorK', {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
}).then(_ => {
|
||||
this.canvas.addEventListener("mousemove", this.onMouseMove)
|
||||
});
|
||||
|
||||
this.stateService.beginStroke({
|
||||
color: this.currentColor,
|
||||
width: this.currentWidth,
|
||||
points: [{
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
}]
|
||||
,
|
||||
}).then((id: number) => {
|
||||
console.log("begin stroke", id)
|
||||
this.currentStroke = id;
|
||||
this.canvas.addEventListener("mousemove", this.addPointToStroke)
|
||||
})
|
||||
}
|
||||
|
||||
private setupCanvasEvents() {
|
||||
this.canvas.addEventListener("mousedown", this.onMouseDown);
|
||||
this.canvas.addEventListener("mousedown", this.beginStroke);
|
||||
|
||||
this.canvas.addEventListener("mouseup", () => {
|
||||
this.canvas.removeEventListener('mousemove', this.onMouseMove)
|
||||
this.stateService.set('cursorK', undefined)
|
||||
this.canvas.removeEventListener('mousemove', this.addPointToStroke)
|
||||
});
|
||||
|
||||
this.canvas.removeEventListener("mouseleave", () => {
|
||||
this.canvas.removeEventListener('mousemove', this.onMouseMove)
|
||||
this.stateService.set('cursorK', undefined)
|
||||
this.canvas.removeEventListener('mousemove', this.addPointToStroke)
|
||||
});
|
||||
|
||||
this.colorPicker?.addEventListener('input', (e) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
console.log(target.value)
|
||||
this.currentColor = target.value
|
||||
|
||||
});
|
||||
|
||||
this.widthSlider?.addEventListener('input', (e) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
this.widthValue!.innerHTML = target.value
|
||||
this.currentWidth = Number(target.value)
|
||||
});
|
||||
|
||||
this.menuButton.addEventListener('click', () => {
|
||||
this.drawer.classList.toggle('open');
|
||||
});
|
||||
|
||||
this.closeButton.addEventListener('click', () => {
|
||||
this.drawer.classList.toggle('open');
|
||||
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if(!e.target){
|
||||
return
|
||||
}
|
||||
if (!this.drawer.contains(e.target as Node) && !this.menuButton.contains(e.target as Node)) {
|
||||
this.drawer.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private resizeCanvas() {
|
||||
@@ -79,4 +124,139 @@ export class ClientDrawService {
|
||||
this.canvas.width = size;
|
||||
this.canvas.height = size;
|
||||
}
|
||||
|
||||
private drawCurve(points: Point[], density: number) {
|
||||
if (points.length < 2) return;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const width1 = getBrushWidthAt(i, points.length);
|
||||
const width2 = getBrushWidthAt(i + 1, points.length);
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
drawVariableWidthSegment(this.canvasContext, p1, p2, width1, width2);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a point on a Catmull-Rom spline segment at parameter t
|
||||
*/
|
||||
export function catmullRomPoint(
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
t: number
|
||||
): Point {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
|
||||
const x =
|
||||
0.5 *
|
||||
(2 * p1.x +
|
||||
(-p0.x + p2.x) * t +
|
||||
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
||||
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
|
||||
|
||||
const y =
|
||||
0.5 *
|
||||
(2 * p1.y +
|
||||
(-p0.y + p2.y) * t +
|
||||
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
||||
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of raw input points into a smooth Catmull-Rom spline
|
||||
*/
|
||||
export function getCatmullRomPath(
|
||||
points: Point[],
|
||||
density: number,
|
||||
): Point[] {
|
||||
const segmentsPerInterval = density;
|
||||
|
||||
if (points.length === 0) return [];
|
||||
if (points.length === 1) return [{ ...points[0] }];
|
||||
|
||||
const smoothed: Point[] = [];
|
||||
const n = points.length;
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const p0 = points[Math.max(0, i - 1)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(n - 1, i + 2)];
|
||||
|
||||
const step = 1 / segmentsPerInterval;
|
||||
|
||||
for (let s = 0; s <= segmentsPerInterval; s++) {
|
||||
const t = s * step;
|
||||
const pt = catmullRomPoint(p0, p1, p2, p3, t);
|
||||
smoothed.push(pt);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove near-duplicates at segment boundaries
|
||||
const result = smoothed.filter((pt, idx, arr) => {
|
||||
if (idx === 0) return true;
|
||||
const prev = arr[idx - 1];
|
||||
return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number {
|
||||
// Example: taper at start and end + speed-based variation
|
||||
const t = index / (totalPoints - 1);
|
||||
let width = baseWidth;
|
||||
|
||||
// Ease in / ease out
|
||||
if (t < 0.1) width *= t * 10;
|
||||
if (t > 0.9) width *= (1 - t) * 10;
|
||||
|
||||
return Math.max(1, width);
|
||||
}
|
||||
|
||||
export function drawVariableWidthSegment(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
width1: number,
|
||||
width2: number
|
||||
): void {
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy)
|
||||
|
||||
if (len < 0.001) return; // Points are too close
|
||||
|
||||
// Normalized perpendicular vector (rotated 90 degrees)
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
|
||||
const halfW1 = width1 / 2;
|
||||
const halfW2 = width2 / 2;
|
||||
|
||||
// Four corners of the quadrilateral
|
||||
const x1 = p1.x + nx * halfW1;
|
||||
const y1 = p1.y + ny * halfW1;
|
||||
const x2 = p1.x - nx * halfW1;
|
||||
const y2 = p1.y - ny * halfW1;
|
||||
const x3 = p2.x - nx * halfW2;
|
||||
const y3 = p2.y - ny * halfW2;
|
||||
const x4 = p2.x + nx * halfW2;
|
||||
const y4 = p2.y + ny * halfW2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.lineTo(x3, y3);
|
||||
ctx.lineTo(x4, y4);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fill();
|
||||
}
|
||||
@@ -1,36 +1,30 @@
|
||||
import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index";
|
||||
import { CanvasState } from "../../model/canvas-state";
|
||||
import { CanvasState, Point, Stroke } from "../../model/canvas-state";
|
||||
|
||||
|
||||
export class ClientStateService {
|
||||
|
||||
private remoteService: any
|
||||
private canvasState: CanvasState = {}
|
||||
private canvasState: CanvasState = { strokes: [] }
|
||||
|
||||
getCursors() {
|
||||
return [this.canvasState?.cursorK, this.canvasState?.cursorP].filter(cursor => cursor !== undefined)
|
||||
}
|
||||
|
||||
async set<K extends keyof CanvasState>(k: K, v: CanvasState[K]) {
|
||||
if(v === null || v === undefined){
|
||||
delete this.canvasState[k]
|
||||
}
|
||||
await this.remoteService.set(k, v)
|
||||
}
|
||||
|
||||
get<K extends keyof CanvasState>(k: K): CanvasState[K] {
|
||||
return this.canvasState[k]
|
||||
getStrokes() {
|
||||
return this.canvasState?.strokes ?? []
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const sock = await new RPCSocket(8080, 'localhost').connect();
|
||||
this.remoteService = sock['StateService']
|
||||
this.canvasState = await this.remoteService.listen((state: CanvasState) => {
|
||||
console.log(state)
|
||||
this.canvasState = state
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
beginStroke = async (stroke: Stroke) => {
|
||||
return await this.remoteService.beginStroke(stroke)
|
||||
}
|
||||
|
||||
addPoint = async (strokeId: number, point: Point) =>{
|
||||
return this.remoteService.addPoint(strokeId, point)
|
||||
}
|
||||
}
|
||||
+149
-3
@@ -7,20 +7,166 @@
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
background: #e0e0e0;
|
||||
font-family: Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Burger Menu Button */
|
||||
.menu-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
.drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -500px; /* Fully hidden when closed */
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
box-shadow: -6px 0 25px rgba(0, 0, 0, 0.18);
|
||||
transition: right 0.35s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
padding: 20px 24px;
|
||||
z-index: 200;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drawer.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.drawer h2 {
|
||||
margin: 0;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.control {
|
||||
margin-bottom: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
padding: 4px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #0066ff;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
align-self: flex-start;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<!-- Canvas Area -->
|
||||
<div class="canvas-container">
|
||||
<canvas id="canvas" width="1200" height="800"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Burger Menu Button -->
|
||||
<button class="menu-button" id="menuButton">☰</button>
|
||||
|
||||
<!-- Sliding Drawer -->
|
||||
<div class="drawer" id="drawer">
|
||||
<div class="drawer-header">
|
||||
<h2>Brush Settings</h2>
|
||||
<button class="close-button" id="closeButton">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="control">
|
||||
<label for="colorPicker">Color</label>
|
||||
<input type="color" id="colorPicker" value="#000000">
|
||||
</div>
|
||||
|
||||
<!-- Brush Width -->
|
||||
<div class="control">
|
||||
<label for="widthSlider">Brush Size</label>
|
||||
<input type="range" id="widthSlider" min="1" max="100" value="12">
|
||||
<span id="widthValue" class="value">12 px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +1,48 @@
|
||||
import { Singleton, Initializable } from "depents";
|
||||
import { RPCExporter } from "rpclibrary";
|
||||
import { CanvasState } from "../../../client/model/canvas-state";
|
||||
import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state";
|
||||
|
||||
@Singleton()
|
||||
export class StateService implements Initializable, RPCExporter {
|
||||
name = 'StateService' as const
|
||||
|
||||
|
||||
private canvasState: CanvasState = {}
|
||||
private canvasState: CanvasState = { strokes: [] }
|
||||
private clients: Array<(state: any) => void> = []
|
||||
|
||||
initialize() {
|
||||
this.canvasState = {}
|
||||
this.canvasState = { strokes: [] }
|
||||
this.clients = []
|
||||
};
|
||||
|
||||
set = async <K extends keyof CanvasState>(k: K, v: CanvasState[K]): Promise<void> => {
|
||||
if (v === null || v === undefined) {
|
||||
console.log("unset", k)
|
||||
delete this.canvasState[k]
|
||||
} else {
|
||||
console.log("set", k, v)
|
||||
this.canvasState[k] = v
|
||||
}
|
||||
this.clients.forEach((client) => {
|
||||
client(this.canvasState)
|
||||
})
|
||||
beginStroke = async (stroke: Stroke) => {
|
||||
const strokeId = this.canvasState.strokes.length
|
||||
this.canvasState.strokes.push(stroke)
|
||||
console.log("beginStroke", strokeId, stroke)
|
||||
this.updateclients()
|
||||
return strokeId
|
||||
}
|
||||
|
||||
addPoint = async (strokeId: number, point: Point) => {
|
||||
this.canvasState.strokes.length
|
||||
this.canvasState.strokes[strokeId].points.push(point)
|
||||
this.updateclients()
|
||||
}
|
||||
|
||||
listen = async (callback: (state: any) => Promise<void>) => {
|
||||
console.log("New client", this.canvasState)
|
||||
await callback(this.canvasState)
|
||||
this.clients = [...this.clients, callback]
|
||||
return this.canvasState
|
||||
}
|
||||
|
||||
private updateclients = () => {
|
||||
this.clients.forEach((client) => {
|
||||
client(this.canvasState)
|
||||
})
|
||||
}
|
||||
|
||||
RPCs = [
|
||||
this.set,
|
||||
this.beginStroke,
|
||||
this.addPoint,
|
||||
{
|
||||
name: 'listen' as const,
|
||||
hook: (cb: any) => { this.listen(cb) }
|
||||
|
||||
Reference in New Issue
Block a user