implement pixel undos (fixes #5)
This commit is contained in:
parent
f81c98abe5
commit
45ad449f4e
|
@ -1,10 +1,10 @@
|
|||
import { Header } from "./Header";
|
||||
import { AppContext } from "../contexts/AppContext";
|
||||
import { CanvasWrapper } from "./CanvasWrapper";
|
||||
import { Pallete } from "./Pallete";
|
||||
import { TemplateContext } from "../contexts/TemplateContext";
|
||||
import { SettingsSidebar } from "./Settings/SettingsSidebar";
|
||||
import { DebugModal } from "./Debug/DebugModal";
|
||||
import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
|
@ -12,7 +12,7 @@ const App = () => {
|
|||
<TemplateContext>
|
||||
<Header />
|
||||
<CanvasWrapper />
|
||||
<Pallete />
|
||||
<ToolbarWrapper />
|
||||
|
||||
<DebugModal />
|
||||
<SettingsSidebar />
|
||||
|
|
|
@ -6,11 +6,11 @@ import {
|
|||
useDisclosure,
|
||||
} from "@nextui-org/react";
|
||||
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
|
||||
import { useAppContext } from "../contexts/AppContext";
|
||||
import { Canvas } from "../lib/canvas";
|
||||
import { useAppContext } from "../../contexts/AppContext";
|
||||
import { Canvas } from "../../lib/canvas";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ClientConfig } from "@sc07-canvas/lib/src/net";
|
||||
import network from "../lib/network";
|
||||
import network from "../../lib/network";
|
||||
|
||||
const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
|
||||
// this implementation matches the server's implementation
|
|
@ -1,12 +1,16 @@
|
|||
#pallete {
|
||||
#toolbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#pallete {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
|
||||
background-color: #fff;
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useAppContext } from "../contexts/AppContext";
|
||||
import { Canvas } from "../lib/canvas";
|
||||
import { IPalleteContext } from "../types";
|
||||
import { useAppContext } from "../../contexts/AppContext";
|
||||
import { Canvas } from "../../lib/canvas";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { CanvasMeta } from "./CanvasMeta";
|
||||
import { IPalleteContext } from "@sc07-canvas/lib/src/net";
|
||||
|
||||
export const Pallete = () => {
|
||||
export const Palette = () => {
|
||||
const { config, user } = useAppContext();
|
||||
const [pallete, setPallete] = useState<IPalleteContext>({});
|
||||
|
||||
|
@ -18,8 +17,6 @@ export const Pallete = () => {
|
|||
|
||||
return (
|
||||
<div id="pallete">
|
||||
<CanvasMeta />
|
||||
|
||||
<div className="pallete-colors">
|
||||
<button
|
||||
aria-label="Deselect Color"
|
|
@ -0,0 +1,17 @@
|
|||
import { CanvasMeta } from "./CanvasMeta";
|
||||
import { Palette } from "./Palette";
|
||||
import { UndoButton } from "./UndoButton";
|
||||
|
||||
/**
|
||||
* Wrapper for everything aligned at the bottom of the screen
|
||||
*/
|
||||
export const ToolbarWrapper = () => {
|
||||
return (
|
||||
<div id="toolbar">
|
||||
<CanvasMeta />
|
||||
<UndoButton />
|
||||
|
||||
<Palette />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import { Button } from "@nextui-org/react";
|
||||
import { useAppContext } from "../../contexts/AppContext";
|
||||
import network from "../../lib/network";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const UndoButton = () => {
|
||||
const { undo, config } = useAppContext();
|
||||
/**
|
||||
* percentage of time left (0 <= x <= 1)
|
||||
*/
|
||||
const [progress, setProgress] = useState(0.5);
|
||||
|
||||
useEffect(() => {
|
||||
if (!undo) {
|
||||
setProgress(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
let diff = undo.expireAt - Date.now();
|
||||
let percentage = diff / config.canvas.undo.grace_period;
|
||||
setProgress(percentage);
|
||||
|
||||
if (percentage <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [undo]);
|
||||
|
||||
// ref-ify this?
|
||||
function execUndo() {
|
||||
network.socket.emitWithAck("undo").then((data) => {
|
||||
console.log("undo", data);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-0"
|
||||
style={{
|
||||
top: undo?.available && progress >= 0 ? "-10px" : "100%",
|
||||
left: "50%",
|
||||
transform: "translateY(-100%) translateX(-50%)",
|
||||
transition: "all 0.25s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<Button onPress={execUndo}>
|
||||
<span className="z-[1]">Undo</span>
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-white/50 transition-all"
|
||||
style={{ width: progress * 100 + "%" }}
|
||||
></div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -25,6 +25,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
||||
|
||||
const [pixels, setPixels] = useState({ available: 0 });
|
||||
const [undo, setUndo] = useState<{ available: true; expireAt: number }>();
|
||||
|
||||
// overlays visible
|
||||
const [settingsSidebar, setSettingsSidebar] = useState(false);
|
||||
|
@ -43,10 +44,21 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
setPixels(pixels);
|
||||
}
|
||||
|
||||
function handleUndo(
|
||||
data: { available: false } | { available: true; expireAt: number }
|
||||
) {
|
||||
if (data.available) {
|
||||
setUndo({ available: true, expireAt: data.expireAt });
|
||||
} else {
|
||||
setUndo(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
Network.on("user", handleUser);
|
||||
Network.on("config", handleConfig);
|
||||
Network.waitFor("pixels").then(([data]) => handlePixels(data));
|
||||
Network.on("pixels", handlePixels);
|
||||
Network.on("undo", handleUndo);
|
||||
|
||||
Network.socket.connect();
|
||||
|
||||
|
@ -69,6 +81,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
pixels,
|
||||
settingsSidebar,
|
||||
setSettingsSidebar,
|
||||
undo,
|
||||
}}
|
||||
>
|
||||
{config ? children : "Loading..."}
|
||||
|
|
|
@ -163,6 +163,12 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
} else {
|
||||
// TODO: handle undo pixel
|
||||
alert("error: " + ack.error);
|
||||
console.warn(
|
||||
"Attempted to place pixel",
|
||||
{ x, y, color: this.Pallete.getSelectedColor()!.id },
|
||||
"and got error",
|
||||
ack
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -16,6 +16,9 @@ export interface INetworkEvents {
|
|||
pixelLastPlaced: (time: number) => void;
|
||||
online: (count: number) => void;
|
||||
pixel: (pixel: Pixel) => void;
|
||||
undo: (
|
||||
data: { available: false } | { available: true; expireAt: number }
|
||||
) => void;
|
||||
}
|
||||
|
||||
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
|
||||
|
@ -66,18 +69,9 @@ class Network extends EventEmitter<INetworkEvents> {
|
|||
this.emit("pixel", pixel);
|
||||
});
|
||||
|
||||
// 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("undo", (undo) => {
|
||||
this.emit("undo", undo);
|
||||
});
|
||||
}
|
||||
|
||||
private _emit: typeof this.emit = (event, ...args) => {
|
||||
|
|
|
@ -86,6 +86,7 @@ header#main-header {
|
|||
#canvas-meta {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
|
@ -194,6 +195,6 @@ main {
|
|||
}
|
||||
}
|
||||
|
||||
@import "./components/Pallete.scss";
|
||||
@import "./components/Toolbar/Palette.scss";
|
||||
@import "./components/Template.scss";
|
||||
@import "./board.scss";
|
||||
|
|
|
@ -8,6 +8,9 @@ export interface ServerToClientEvents {
|
|||
online: (count: { count: number }) => void;
|
||||
availablePixels: (count: number) => void;
|
||||
pixelLastPlaced: (time: number) => void;
|
||||
undo: (
|
||||
data: { available: false } | { available: true; expireAt: number }
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
|
@ -20,10 +23,12 @@ export interface ClientToServerEvents {
|
|||
>
|
||||
) => void
|
||||
) => void;
|
||||
undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void;
|
||||
}
|
||||
|
||||
// app context
|
||||
|
||||
// TODO: move to client/{...}/AppContext.tsx
|
||||
export interface IAppContext {
|
||||
config: ClientConfig;
|
||||
user?: AuthSession;
|
||||
|
@ -34,6 +39,7 @@ export interface IAppContext {
|
|||
pixels: { available: number };
|
||||
settingsSidebar: boolean;
|
||||
setSettingsSidebar: (v: boolean) => void;
|
||||
undo?: { available: true; expireAt: number };
|
||||
}
|
||||
|
||||
export interface IPalleteContext {
|
||||
|
@ -56,6 +62,9 @@ export interface IPosition {
|
|||
export type Pixel = {
|
||||
x: number;
|
||||
y: number;
|
||||
/**
|
||||
* Palette color ID or -1 for nothing
|
||||
*/
|
||||
color: number;
|
||||
};
|
||||
|
||||
|
@ -73,6 +82,12 @@ export type CanvasConfig = {
|
|||
cooldown: number;
|
||||
multiplier: number;
|
||||
};
|
||||
undo: {
|
||||
/**
|
||||
* time in ms to allow undos
|
||||
*/
|
||||
grace_period: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
|
|
|
@ -6,6 +6,7 @@ Table User {
|
|||
sub String [pk]
|
||||
lastPixelTime DateTime [default: `now()`, not null]
|
||||
pixelStack Int [not null, default: 0]
|
||||
undoExpires DateTime
|
||||
pixels Pixel [not null]
|
||||
FactionMember FactionMember [not null]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "undoExpires" TIMESTAMP(3);
|
|
@ -15,9 +15,10 @@ datasource db {
|
|||
}
|
||||
|
||||
model User {
|
||||
sub String @id
|
||||
lastPixelTime DateTime @default(now()) // the time the last pixel was placed at
|
||||
pixelStack Int @default(0) // amount of pixels stacked for this user
|
||||
sub String @id
|
||||
lastPixelTime DateTime @default(now()) // the time the last pixel was placed at
|
||||
pixelStack Int @default(0) // amount of pixels stacked for this user
|
||||
undoExpires DateTime? // when the undo for the most recent pixel expires at
|
||||
|
||||
pixels Pixel[]
|
||||
FactionMember FactionMember[]
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { CanvasConfig } from "@sc07-canvas/lib/src/net";
|
||||
import { prisma } from "./prisma";
|
||||
import { Redis } from "./redis";
|
||||
import { SocketServer } from "./SocketServer";
|
||||
|
||||
class Canvas {
|
||||
private CANVAS_SIZE: [number, number];
|
||||
|
@ -18,6 +19,9 @@ class Canvas {
|
|||
multiplier: 3,
|
||||
maxStack: 6,
|
||||
},
|
||||
undo: {
|
||||
grace_period: 5000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -123,6 +127,43 @@ class Canvas {
|
|||
// i don't think it needs to be awaited
|
||||
await this.updateCanvasRedisAtPos(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a pixel to be updated in redis
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
async refreshPixel(x: number, y: number) {
|
||||
const redis = await Redis.getClient();
|
||||
const key = Redis.key("pixelColor", x, y);
|
||||
|
||||
// find if any pixels exist at this spot, and pick the most recent one
|
||||
const pixel = await prisma.pixel.findFirst({
|
||||
where: { x, y },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
let paletteColorID = -1;
|
||||
|
||||
// if pixel exists in redis
|
||||
if (pixel) {
|
||||
redis.set(key, pixel.color);
|
||||
paletteColorID = (await prisma.paletteColor.findFirst({
|
||||
where: { hex: pixel.color },
|
||||
}))!.id;
|
||||
} else {
|
||||
redis.del(key);
|
||||
}
|
||||
|
||||
await this.updateCanvasRedisAtPos(x, y);
|
||||
|
||||
// announce to everyone the pixel's color
|
||||
// using -1 if no pixel is there anymore
|
||||
SocketServer.instance.io.emit("pixel", {
|
||||
x,
|
||||
y,
|
||||
color: paletteColorID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Canvas();
|
||||
|
|
|
@ -66,9 +66,12 @@ const getClientConfig = (): ClientConfig => {
|
|||
type Socket = RawSocket<ClientToServerEvents, ServerToClientEvents>;
|
||||
|
||||
export class SocketServer {
|
||||
static instance: SocketServer;
|
||||
io: Server<ClientToServerEvents, ServerToClientEvents>;
|
||||
|
||||
constructor(server: http.Server) {
|
||||
SocketServer.instance = this;
|
||||
|
||||
this.io = new Server(server, getSocketConfig());
|
||||
|
||||
this.setupMasterShard();
|
||||
|
@ -206,6 +209,10 @@ export class SocketServer {
|
|||
|
||||
await user.modifyStack(-1);
|
||||
await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex);
|
||||
// give undo capabilities
|
||||
await user.setUndo(
|
||||
new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
|
||||
);
|
||||
|
||||
const newPixel: Pixel = {
|
||||
x: pixel.x,
|
||||
|
@ -218,6 +225,52 @@ export class SocketServer {
|
|||
});
|
||||
socket.broadcast.emit("pixel", newPixel);
|
||||
});
|
||||
|
||||
socket.on("undo", async (ack) => {
|
||||
if (!user) {
|
||||
ack({ success: false, error: "no_user" });
|
||||
return;
|
||||
}
|
||||
|
||||
await user.update(true);
|
||||
|
||||
if (!user.undoExpires) {
|
||||
// user has no undo available
|
||||
ack({ success: false, error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpired = user.undoExpires.getTime() - Date.now() < 0;
|
||||
|
||||
if (isExpired) {
|
||||
// expiration date is in the past, so no undo is available
|
||||
ack({ success: false, error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
// find most recent pixel
|
||||
const pixel = await prisma.pixel.findFirst({
|
||||
where: { userId: user.sub },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!pixel) {
|
||||
// user doesn't have a pixel, idk how we got here, but they can't do anything
|
||||
ack({ success: false, error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
// mark the undo as used
|
||||
await user.setUndo();
|
||||
|
||||
// delete most recent pixel
|
||||
await prisma.pixel.delete({ where: { id: pixel.id } });
|
||||
|
||||
// trigger re-cache on redis
|
||||
await Canvas.refreshPixel(pixel.x, pixel.y);
|
||||
|
||||
ack({ success: true, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import { Socket } from "socket.io";
|
||||
import { Logger } from "../lib/Logger";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { AuthSession } from "@sc07-canvas/lib/src/net";
|
||||
import {
|
||||
AuthSession,
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
|
||||
interface IUserData {
|
||||
sub: string;
|
||||
lastPixelTime: Date;
|
||||
pixelStack: number;
|
||||
undoExpires: Date | null;
|
||||
}
|
||||
|
||||
export class User {
|
||||
|
@ -16,8 +21,9 @@ export class User {
|
|||
lastPixelTime: Date;
|
||||
pixelStack: number;
|
||||
authSession?: AuthSession;
|
||||
undoExpires?: Date;
|
||||
|
||||
sockets: Set<Socket> = new Set();
|
||||
sockets: Set<Socket<ClientToServerEvents, ServerToClientEvents>> = new Set();
|
||||
|
||||
private _updatedAt: number;
|
||||
|
||||
|
@ -27,6 +33,7 @@ export class User {
|
|||
this.sub = data.sub;
|
||||
this.lastPixelTime = data.lastPixelTime;
|
||||
this.pixelStack = data.pixelStack;
|
||||
this.undoExpires = data.undoExpires || undefined;
|
||||
|
||||
this._updatedAt = Date.now();
|
||||
}
|
||||
|
@ -44,6 +51,7 @@ export class User {
|
|||
|
||||
this.lastPixelTime = userData.lastPixelTime;
|
||||
this.pixelStack = userData.pixelStack;
|
||||
this.undoExpires = userData.undoExpires || undefined;
|
||||
}
|
||||
|
||||
async modifyStack(modifyBy: number): Promise<any> {
|
||||
|
@ -62,6 +70,41 @@ export class User {
|
|||
await this.update(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set undoExpires in database and notify all user's sockets of undo ttl
|
||||
*/
|
||||
async setUndo(expires?: Date) {
|
||||
if (expires) {
|
||||
// expiration being set
|
||||
|
||||
await prisma.user.update({
|
||||
where: { sub: this.sub },
|
||||
data: {
|
||||
undoExpires: expires,
|
||||
},
|
||||
});
|
||||
|
||||
for (const socket of this.sockets) {
|
||||
socket.emit("undo", { available: true, expireAt: expires.getTime() });
|
||||
}
|
||||
} else {
|
||||
// clear undo capability
|
||||
|
||||
await prisma.user.update({
|
||||
where: { sub: this.sub },
|
||||
data: {
|
||||
undoExpires: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
for (const socket of this.sockets) {
|
||||
socket.emit("undo", { available: false });
|
||||
}
|
||||
}
|
||||
|
||||
await this.update(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this user data is stale and should be updated
|
||||
* @see User#update
|
||||
|
|
Loading…
Reference in New Issue