implement pixel undos (fixes #5)

This commit is contained in:
Grant 2024-04-27 22:44:04 -06:00
parent f81c98abe5
commit 45ad449f4e
17 changed files with 279 additions and 31 deletions

View File

@ -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 />

View File

@ -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

View File

@ -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;

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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..."}

View File

@ -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
);
}
});
}

View File

@ -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) => {

View File

@ -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";

View File

@ -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 = {

View File

@ -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]
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "undoExpires" TIMESTAMP(3);

View File

@ -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[]

View File

@ -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();

View File

@ -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: {} });
});
}
/**

View File

@ -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