소스 검색

Working multiplayer + brush-like strokes

master
Peter Millauer 3 일 전
부모
커밋
03d681661a

+ 8
- 3
src/client/model/canvas-state.ts 파일 보기

@@ -1,9 +1,14 @@
1 1
 export type CanvasState = {
2
-    cursorP?: Cursor,
3
-    cursorK?: Cursor
2
+    strokes: Stroke[],
4 3
 }
5 4
 
6
-export type Cursor = {
5
+export type Stroke = {
6
+    points: Point[],
7
+    color: string,
8
+    width: number
9
+}
10
+
11
+export type Point = {
7 12
     x: number, 
8 13
     y: number,
9 14
 }

+ 210
- 30
src/client/services/draw/draw.client-service.ts 파일 보기

@@ -1,4 +1,4 @@
1
-import { Cursor } from "../../model/canvas-state";
1
+import { Point, Stroke } from "../../model/canvas-state";
2 2
 import { ClientStateService } from "../state/state.client-service";
3 3
 
4 4
 
@@ -6,72 +6,117 @@ import { ClientStateService } from "../state/state.client-service";
6 6
 export class ClientDrawService {
7 7
     private readonly canvasContext: CanvasRenderingContext2D
8 8
 
9
-    constructor(
9
+    private currentColor
10
+
11
+    private currentWidth = 12;
10 12
 
13
+    private currentStroke?: number
14
+
15
+    constructor(
11 16
         private readonly stateService: ClientStateService,
12
-        private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement
17
+        private readonly canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement,
18
+        private readonly colorPicker = document.getElementById('colorPicker')!,
19
+        private readonly widthSlider = document.getElementById('widthSlider')!,
20
+        private readonly widthValue = document.getElementById('widthValue')!,
21
+        private readonly menuButton = document.getElementById('menuButton')!,
22
+        private readonly drawer = document.getElementById('drawer')!,
23
+        private readonly closeButton = document.getElementById('closeButton')!,
13 24
     ) {
14 25
         this.resizeCanvas()
15 26
         this.setupCanvasEvents()
16 27
         window.addEventListener("resize", () => this.resizeCanvas());
17 28
         this.canvasContext = canvas.getContext("2d")!
29
+        this.currentColor = colorPicker?.getAttribute('value') ?? "#eaafff"
18 30
     }
19 31
 
20 32
     draw() {
21
-        const cursors: Cursor[] = this.stateService.getCursors()
22
-        
33
+        const strokes: Stroke[] = this.stateService.getStrokes()
34
+
23 35
         this.canvasContext.globalAlpha = 0.05;
24 36
         this.canvasContext.fillStyle = "white";
25
-        this.canvasContext.fillRect(0, 0, this.canvas.width, this.canvas.height);
37
+        this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
26 38
         this.canvasContext.globalAlpha = 1;
27 39
 
28
-        cursors
29
-            .forEach(cursor => {
30
-                this.canvasContext.beginPath();
31
-                this.canvasContext.arc(cursor.x, cursor.y, 30, 0, Math.PI * 2);
32
-                this.canvasContext.fillStyle = "#eaafff";
33
-                this.canvasContext.fill();
34
-            })
40
+        strokes.forEach(stroke => {
41
+            this.canvasContext.fillStyle = stroke.color;
42
+            this.drawCurve(stroke.points, stroke.width)
43
+        })
35 44
 
36 45
         requestAnimationFrame(() => this.draw())
37 46
     }
38 47
 
39
-    private onMouseMove = (e: any) => {
40
-        const cursor = this.stateService.get('cursorK')
41
-        if (!cursor) {
48
+
49
+    private addPointToStroke = (e: any) => {
50
+        if (this.currentStroke === undefined) {
42 51
             return
43 52
         }
44 53
         const rect = this.canvas.getBoundingClientRect();
45
-        this.stateService.set('cursorK', {
54
+        this.stateService.addPoint(this.currentStroke, {
46 55
             x: e.clientX - rect.left,
47 56
             y: e.clientY - rect.top,
48 57
         })
49 58
     };
50 59
 
51
-    private onMouseDown = (e: any) => {
60
+    private beginStroke = (e: any) => {
52 61
         const rect = this.canvas.getBoundingClientRect();
53 62
 
54
-        this.stateService.set('cursorK', {
55
-            x: e.clientX - rect.left,
56
-            y: e.clientY - rect.top,
57
-        }).then(_ => {
58
-            this.canvas.addEventListener("mousemove", this.onMouseMove)
59
-        });
60
-
63
+        this.stateService.beginStroke({
64
+            color: this.currentColor,
65
+            width: this.currentWidth,
66
+            points: [{
67
+                x: e.clientX - rect.left,
68
+                y: e.clientY - rect.top
69
+            }]
70
+            ,
71
+        }).then((id: number) => {
72
+            console.log("begin stroke", id)
73
+            this.currentStroke = id;
74
+            this.canvas.addEventListener("mousemove", this.addPointToStroke)
75
+        })
61 76
     }
62 77
 
63 78
     private setupCanvasEvents() {
64
-        this.canvas.addEventListener("mousedown", this.onMouseDown);
79
+        this.canvas.addEventListener("mousedown", this.beginStroke);
65 80
 
66 81
         this.canvas.addEventListener("mouseup", () => {
67
-            this.canvas.removeEventListener('mousemove', this.onMouseMove)
68
-            this.stateService.set('cursorK', undefined)
82
+            this.canvas.removeEventListener('mousemove', this.addPointToStroke)
69 83
         });
70 84
 
71 85
         this.canvas.removeEventListener("mouseleave", () => {
72
-            this.canvas.removeEventListener('mousemove', this.onMouseMove)
73
-            this.stateService.set('cursorK', undefined)
86
+            this.canvas.removeEventListener('mousemove', this.addPointToStroke)
74 87
         });
88
+
89
+        this.colorPicker?.addEventListener('input', (e) => {
90
+            const target = e.target as HTMLTextAreaElement
91
+            console.log(target.value)
92
+            this.currentColor = target.value
93
+
94
+        });
95
+
96
+        this.widthSlider?.addEventListener('input', (e) => {
97
+            const target = e.target as HTMLTextAreaElement
98
+            this.widthValue!.innerHTML = target.value
99
+            this.currentWidth = Number(target.value)
100
+        });
101
+
102
+        this.menuButton.addEventListener('click', () => {
103
+            this.drawer.classList.toggle('open');
104
+        });
105
+
106
+        this.closeButton.addEventListener('click', () => {
107
+            this.drawer.classList.toggle('open');
108
+
109
+        });
110
+
111
+        document.addEventListener('click', (e) => {
112
+            if(!e.target){
113
+                return
114
+            }
115
+            if (!this.drawer.contains(e.target as Node) && !this.menuButton.contains(e.target as Node)) {
116
+                this.drawer.classList.remove('open');
117
+            }
118
+        });
119
+
75 120
     }
76 121
 
77 122
     private resizeCanvas() {
@@ -79,4 +124,139 @@ export class ClientDrawService {
79 124
         this.canvas.width = size;
80 125
         this.canvas.height = size;
81 126
     }
127
+
128
+    private drawCurve(points: Point[], density: number) {
129
+        if (points.length < 2) return;
130
+
131
+        for (let i = 0; i < points.length - 1; i++) {
132
+            const width1 = getBrushWidthAt(i, points.length);
133
+            const width2 = getBrushWidthAt(i + 1, points.length);
134
+            const p1 = points[i];
135
+            const p2 = points[i + 1];
136
+            drawVariableWidthSegment(this.canvasContext, p1, p2, width1, width2);
137
+
138
+        }
139
+    }
140
+}
141
+
142
+/**
143
+ * Returns a point on a Catmull-Rom spline segment at parameter t
144
+ */
145
+export function catmullRomPoint(
146
+    p0: Point,
147
+    p1: Point,
148
+    p2: Point,
149
+    p3: Point,
150
+    t: number
151
+): Point {
152
+    const t2 = t * t;
153
+    const t3 = t2 * t;
154
+
155
+    const x =
156
+        0.5 *
157
+        (2 * p1.x +
158
+            (-p0.x + p2.x) * t +
159
+            (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
160
+            (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
161
+
162
+    const y =
163
+        0.5 *
164
+        (2 * p1.y +
165
+            (-p0.y + p2.y) * t +
166
+            (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
167
+            (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
168
+
169
+    return { x, y };
170
+}
171
+
172
+/**
173
+ * Converts a list of raw input points into a smooth Catmull-Rom spline
174
+ */
175
+export function getCatmullRomPath(
176
+    points: Point[],
177
+    density: number,
178
+): Point[] {
179
+    const segmentsPerInterval = density;
180
+
181
+    if (points.length === 0) return [];
182
+    if (points.length === 1) return [{ ...points[0] }];
183
+
184
+    const smoothed: Point[] = [];
185
+    const n = points.length;
186
+
187
+    for (let i = 0; i < n - 1; i++) {
188
+        const p0 = points[Math.max(0, i - 1)];
189
+        const p1 = points[i];
190
+        const p2 = points[i + 1];
191
+        const p3 = points[Math.min(n - 1, i + 2)];
192
+
193
+        const step = 1 / segmentsPerInterval;
194
+
195
+        for (let s = 0; s <= segmentsPerInterval; s++) {
196
+            const t = s * step;
197
+            const pt = catmullRomPoint(p0, p1, p2, p3, t);
198
+            smoothed.push(pt);
199
+        }
200
+    }
201
+
202
+    // Remove near-duplicates at segment boundaries
203
+    const result = smoothed.filter((pt, idx, arr) => {
204
+        if (idx === 0) return true;
205
+        const prev = arr[idx - 1];
206
+        return Math.hypot(pt.x - prev.x, pt.y - prev.y) > 0.001;
207
+    });
208
+
209
+    return result;
210
+}
211
+
212
+function getBrushWidthAt(index: number, totalPoints: number, baseWidth: number = 12): number {
213
+    // Example: taper at start and end + speed-based variation
214
+    const t = index / (totalPoints - 1);
215
+    let width = baseWidth;
216
+
217
+    // Ease in / ease out
218
+    if (t < 0.1) width *= t * 10;
219
+    if (t > 0.9) width *= (1 - t) * 10;
220
+
221
+    return Math.max(1, width);
222
+}
223
+
224
+export function drawVariableWidthSegment(
225
+    ctx: CanvasRenderingContext2D,
226
+    p1: Point,
227
+    p2: Point,
228
+    width1: number,
229
+    width2: number
230
+): void {
231
+    const dx = p2.x - p1.x;
232
+    const dy = p2.y - p1.y;
233
+    const len = Math.hypot(dx, dy); // More efficient than sqrt(dx*dx + dy*dy)
234
+
235
+    if (len < 0.001) return; // Points are too close
236
+
237
+    // Normalized perpendicular vector (rotated 90 degrees)
238
+    const nx = -dy / len;
239
+    const ny = dx / len;
240
+
241
+    const halfW1 = width1 / 2;
242
+    const halfW2 = width2 / 2;
243
+
244
+    // Four corners of the quadrilateral
245
+    const x1 = p1.x + nx * halfW1;
246
+    const y1 = p1.y + ny * halfW1;
247
+    const x2 = p1.x - nx * halfW1;
248
+    const y2 = p1.y - ny * halfW1;
249
+    const x3 = p2.x - nx * halfW2;
250
+    const y3 = p2.y - ny * halfW2;
251
+    const x4 = p2.x + nx * halfW2;
252
+    const y4 = p2.y + ny * halfW2;
253
+
254
+    ctx.beginPath();
255
+    ctx.moveTo(x1, y1);
256
+    ctx.lineTo(x2, y2);
257
+    ctx.lineTo(x3, y3);
258
+    ctx.lineTo(x4, y4);
259
+    ctx.closePath();
260
+
261
+    ctx.fill();
82 262
 }

+ 12
- 18
src/client/services/state/state.client-service.ts 파일 보기

@@ -1,36 +1,30 @@
1 1
 import { RPCSocket } from "../../../../node_modules/rpclibrary/js/Index";
2
-import { CanvasState } from "../../model/canvas-state";
2
+import { CanvasState, Point, Stroke } from "../../model/canvas-state";
3 3
 
4 4
 
5 5
 export class ClientStateService {
6 6
 
7 7
     private remoteService: any
8
-    private canvasState: CanvasState = {}
8
+    private canvasState: CanvasState = { strokes: [] }
9 9
 
10
-    getCursors() {
11
-        return [this.canvasState?.cursorK, this.canvasState?.cursorP].filter(cursor => cursor !== undefined)
12
-    }
13
-
14
-    async set<K extends keyof CanvasState>(k: K, v: CanvasState[K]) {
15
-        if(v === null || v === undefined){
16
-            delete this.canvasState[k]
17
-        }
18
-        await this.remoteService.set(k, v)
19
-    }
20
-
21
-    get<K extends keyof CanvasState>(k: K): CanvasState[K] {
22
-        return this.canvasState[k]
10
+    getStrokes() {
11
+        return this.canvasState?.strokes ?? []
23 12
     }
24 13
 
25 14
     async connect() {
26 15
         const sock = await new RPCSocket(8080, 'localhost').connect();
27 16
         this.remoteService = sock['StateService']
28 17
         this.canvasState = await this.remoteService.listen((state: CanvasState) => {
29
-            console.log(state)
30
-            this.canvasState = state
31
-            
18
+            this.canvasState = state      
32 19
         })
33 20
     }
34 21
 
35 22
     
23
+    beginStroke = async (stroke: Stroke) => {
24
+        return await this.remoteService.beginStroke(stroke)
25
+    }
26
+
27
+    addPoint = async (strokeId: number, point: Point) =>{
28
+        return this.remoteService.addPoint(strokeId, point)
29
+    }
36 30
 }

+ 150
- 4
src/public/index.html 파일 보기

@@ -7,20 +7,166 @@
7 7
   <style>
8 8
     body {
9 9
       margin: 0;
10
+      height: 100vh;
10 11
       background: #e0e0e0;
12
+      font-family: Arial, sans-serif;
13
+      overflow: hidden;
14
+      position: relative;
15
+    }
16
+
17
+    .canvas-container {
18
+      height: 100vh;
11 19
       display: flex;
12 20
       justify-content: center;
13 21
       align-items: center;
14
-      height: 100vh;
22
+      padding: 20px;
23
+      box-sizing: border-box;
15 24
     }
25
+
16 26
     canvas {
17 27
       background: #ffffff;
18
-      box-shadow: 0 4px 20px rgba(0,0,0,0.1);
28
+      box-shadow: 0 6px 25px rgba(0, 0, 0, 0.15);
29
+      border-radius: 8px;
30
+      max-width: 100%;
31
+      max-height: 100%;
32
+    }
33
+
34
+    /* Burger Menu Button */
35
+    .menu-button {
36
+      position: absolute;
37
+      top: 20px;
38
+      right: 20px;
39
+      width: 50px;
40
+      height: 50px;
41
+      background: white;
42
+      border: none;
43
+      border-radius: 50%;
44
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
45
+      cursor: pointer;
46
+      z-index: 100;
47
+      display: flex;
48
+      align-items: center;
49
+      justify-content: center;
50
+      font-size: 28px;
51
+      color: #333;
52
+    }
53
+
54
+    /* Drawer */
55
+    .drawer {
56
+      position: absolute;
57
+      top: 0;
58
+      right: -500px;           /* Fully hidden when closed */
59
+      width: 300px;
60
+      height: 100vh;
61
+      background: white;
62
+      box-shadow: -6px 0 25px rgba(0, 0, 0, 0.18);
63
+      transition: right 0.35s cubic-bezier(0.32, 0.72, 0, 1);
64
+      padding: 20px 24px;
65
+      z-index: 200;
66
+      overflow-y: auto;
67
+    }
68
+
69
+    .drawer.open {
70
+      right: 0;
71
+    }
72
+
73
+    .drawer-header {
74
+      display: flex;
75
+      justify-content: space-between;
76
+      align-items: center;
77
+      margin-bottom: 24px;
78
+    }
79
+
80
+    .drawer h2 {
81
+      margin: 0;
82
+      color: #222;
83
+    }
84
+
85
+    .close-button {
86
+      background: none;
87
+      border: none;
88
+      font-size: 28px;
89
+      color: #666;
90
+      cursor: pointer;
91
+      width: 40px;
92
+      height: 40px;
93
+      display: flex;
94
+      align-items: center;
95
+      justify-content: center;
96
+      border-radius: 50%;
97
+    }
98
+
99
+    .close-button:hover {
100
+      background: #f0f0f0;
101
+      color: #333;
102
+    }
103
+
104
+    .control {
105
+      margin-bottom: 28px;
106
+      display: flex;
107
+      flex-direction: column;
108
+      gap: 8px;
109
+    }
110
+
111
+    label {
112
+      font-weight: 600;
113
+      color: #333;
114
+    }
115
+
116
+    input[type="color"] {
117
+      width: 80px;
118
+      height: 60px;
119
+      padding: 4px;
120
+      border: 2px solid #ddd;
121
+      border-radius: 8px;
122
+      cursor: pointer;
123
+    }
124
+
125
+    input[type="range"] {
126
+      width: 100%;
127
+      accent-color: #0066ff;
128
+    }
129
+
130
+    .value {
131
+      font-family: monospace;
132
+      background: #f5f5f5;
133
+      padding: 6px 12px;
134
+      border-radius: 6px;
135
+      align-self: flex-start;
136
+      font-weight: bold;
19 137
     }
20 138
   </style>
21 139
 </head>
22 140
 <body>
23
-  <canvas id="canvas"></canvas>
141
+  <!-- Canvas Area -->
142
+  <div class="canvas-container">
143
+    <canvas id="canvas" width="1200" height="800"></canvas>
144
+  </div>
145
+
146
+  <!-- Burger Menu Button -->
147
+  <button class="menu-button" id="menuButton">☰</button>
148
+
149
+  <!-- Sliding Drawer -->
150
+  <div class="drawer" id="drawer">
151
+    <div class="drawer-header">
152
+      <h2>Brush Settings</h2>
153
+      <button class="close-button" id="closeButton">×</button>
154
+    </div>
155
+    
156
+    <!-- Color Picker -->
157
+    <div class="control">
158
+      <label for="colorPicker">Color</label>
159
+      <input type="color" id="colorPicker" value="#000000">
160
+    </div>
161
+
162
+    <!-- Brush Width -->
163
+    <div class="control">
164
+      <label for="widthSlider">Brush Size</label>
165
+      <input type="range" id="widthSlider" min="1" max="100" value="12">
166
+      <span id="widthValue" class="value">12 px</span>
167
+    </div>
168
+  </div>
169
+
24 170
   <script type="module" src="main.js"></script>
25 171
 </body>
26
-</html>
172
+</html>

+ 22
- 17
src/server/services/state/state.service.ts 파일 보기

@@ -1,43 +1,48 @@
1 1
 import { Singleton, Initializable } from "depents";
2 2
 import { RPCExporter } from "rpclibrary";
3
-import { CanvasState } from "../../../client/model/canvas-state";
3
+import { CanvasState, Point, Stroke } from "../../../client/model/canvas-state";
4 4
 
5 5
 @Singleton()
6 6
 export class StateService implements Initializable, RPCExporter {
7 7
     name = 'StateService' as const
8 8
 
9
-
10
-    private canvasState: CanvasState = {}
9
+    private canvasState: CanvasState = { strokes: [] }
11 10
     private clients: Array<(state: any) => void> = []
12 11
 
13 12
     initialize() {
14
-        this.canvasState = {}
13
+        this.canvasState = { strokes: [] }
15 14
         this.clients = []
16 15
     };
17 16
 
18
-    set = async <K extends keyof CanvasState>(k: K, v: CanvasState[K]): Promise<void> => {
19
-        if (v === null || v === undefined) {
20
-            console.log("unset", k)
21
-            delete this.canvasState[k]
22
-        } else {
23
-            console.log("set", k, v)
24
-            this.canvasState[k] = v
25
-        }
26
-        this.clients.forEach((client) => {
27
-            client(this.canvasState)
28
-        })
17
+    beginStroke = async (stroke: Stroke) => {
18
+        const strokeId = this.canvasState.strokes.length 
19
+        this.canvasState.strokes.push(stroke)
20
+        console.log("beginStroke", strokeId, stroke)
21
+        this.updateclients()
22
+        return strokeId
23
+    }
29 24
 
25
+    addPoint = async (strokeId: number, point: Point) => {
26
+        this.canvasState.strokes.length
27
+        this.canvasState.strokes[strokeId].points.push(point)
28
+        this.updateclients()
30 29
     }
31 30
 
32 31
     listen = async (callback: (state: any) => Promise<void>) => {
33
-        console.log("New client", this.canvasState)
34 32
         await callback(this.canvasState)
35 33
         this.clients = [...this.clients, callback]
36 34
         return this.canvasState
37 35
     }
38 36
 
37
+    private updateclients = () => {
38
+        this.clients.forEach((client) => {
39
+            client(this.canvasState)
40
+        })
41
+    }
42
+
39 43
     RPCs = [
40
-        this.set,
44
+        this.beginStroke,
45
+        this.addPoint,
41 46
         {
42 47
             name: 'listen' as const,
43 48
             hook: (cb: any) => { this.listen(cb) }

Loading…
취소
저장