This commit is contained in:
parent
33c20bf59d
commit
a92bfc1d22
14 changed files with 2629 additions and 59 deletions
779
backend/src/controllers/PyramidController.cpp
Normal file
779
backend/src/controllers/PyramidController.cpp
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
#include "PyramidController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include "../common/HttpHelpers.h"
|
||||
#include "../common/AuthHelpers.h"
|
||||
#include <regex>
|
||||
#include <mutex>
|
||||
#include <chrono>
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
// Static members for WebSocket controller
|
||||
std::unordered_map<WebSocketConnectionPtr, PyramidWebSocketController::ConnectionInfo> PyramidWebSocketController::connections_;
|
||||
std::mutex PyramidWebSocketController::connectionsMutex_;
|
||||
|
||||
// ==================== Validation Helpers ====================
|
||||
|
||||
bool PyramidController::isValidPixelPosition(int faceId, int x, int y) {
|
||||
// Face ID must be 0-4
|
||||
if (faceId < 0 || faceId > 4) return false;
|
||||
|
||||
// X and Y must be within face bounds
|
||||
if (x < 0 || x >= FACE_SIZE || y < 0 || y >= FACE_SIZE) return false;
|
||||
|
||||
// For triangular faces (1-4), validate within triangle bounds
|
||||
if (faceId > 0) {
|
||||
// Triangle with base at bottom (y=199), apex at top (y=0)
|
||||
// At y=0, only x=99,100 are valid (center)
|
||||
// At y=199, x=0-199 are valid (full width)
|
||||
int halfWidth = (y + 1) / 2;
|
||||
int center = FACE_SIZE / 2;
|
||||
int minX = center - halfWidth;
|
||||
int maxX = center + halfWidth;
|
||||
if (x < minX || x > maxX) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PyramidController::isValidColor(const std::string &color) {
|
||||
// Must be a valid hex color like #RRGGBB
|
||||
if (color.length() != 7 || color[0] != '#') return false;
|
||||
|
||||
for (int i = 1; i < 7; i++) {
|
||||
char c = std::toupper(color[i]);
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Canvas State Methods ====================
|
||||
|
||||
void PyramidController::getState(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get all pixels grouped by face
|
||||
*dbClient << "SELECT face_id, x, y, color FROM pyramid_pixels ORDER BY face_id, y, x"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["pixels"] = Json::Value(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value pixel;
|
||||
pixel["faceId"] = row["face_id"].as<int>();
|
||||
pixel["x"] = row["x"].as<int>();
|
||||
pixel["y"] = row["y"].as<int>();
|
||||
pixel["color"] = row["color"].as<std::string>();
|
||||
resp["pixels"].append(pixel);
|
||||
}
|
||||
|
||||
resp["totalPixels"] = static_cast<Json::UInt>(r.size());
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get pyramid state");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getState: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidController::getFaceState(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceIdStr) {
|
||||
try {
|
||||
PARSE_ID(faceId, faceIdStr, callback);
|
||||
|
||||
if (faceId < 0 || faceId > 4) {
|
||||
callback(jsonError("Invalid face ID (must be 0-4)"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT x, y, color FROM pyramid_pixels WHERE face_id = $1 ORDER BY y, x"
|
||||
<< static_cast<int>(faceId)
|
||||
>> [callback, faceId](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["faceId"] = static_cast<int>(faceId);
|
||||
resp["pixels"] = Json::Value(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value pixel;
|
||||
pixel["x"] = row["x"].as<int>();
|
||||
pixel["y"] = row["y"].as<int>();
|
||||
pixel["color"] = row["color"].as<std::string>();
|
||||
resp["pixels"].append(pixel);
|
||||
}
|
||||
|
||||
resp["totalPixels"] = static_cast<Json::UInt>(r.size());
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get face state");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getFaceState: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidController::getColors(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT color, name FROM pyramid_colors WHERE is_active = true ORDER BY sort_order"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["colors"] = Json::Value(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value color;
|
||||
color["color"] = row["color"].as<std::string>();
|
||||
color["name"] = row["name"].isNull() ? "" : row["name"].as<std::string>();
|
||||
resp["colors"].append(color);
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get colors");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getColors: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Pixel Operation Methods ====================
|
||||
|
||||
void PyramidController::placePixel(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
CHECK_AUTH(user, callback);
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON body"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!json->isMember("faceId") || !json->isMember("x") ||
|
||||
!json->isMember("y") || !json->isMember("color")) {
|
||||
callback(jsonError("Missing required fields: faceId, x, y, color"));
|
||||
return;
|
||||
}
|
||||
|
||||
int faceId = (*json)["faceId"].asInt();
|
||||
int x = (*json)["x"].asInt();
|
||||
int y = (*json)["y"].asInt();
|
||||
std::string color = (*json)["color"].asString();
|
||||
|
||||
// Validate pixel position
|
||||
if (!isValidPixelPosition(faceId, x, y)) {
|
||||
callback(jsonError("Invalid pixel position"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
if (!isValidColor(color)) {
|
||||
callback(jsonError("Invalid color format (use #RRGGBB)"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Uppercase the color for consistency
|
||||
std::transform(color.begin(), color.end(), color.begin(), ::toupper);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
int64_t userId = user.id;
|
||||
std::string username = user.username;
|
||||
|
||||
// Check daily limit using upsert pattern
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_daily_limits (user_id, date, pixels_placed)
|
||||
VALUES ($1, CURRENT_DATE, 1)
|
||||
ON CONFLICT (user_id, date)
|
||||
DO UPDATE SET pixels_placed = pyramid_daily_limits.pixels_placed + 1
|
||||
WHERE pyramid_daily_limits.pixels_placed < $2
|
||||
RETURNING pixels_placed
|
||||
)"
|
||||
<< userId << DAILY_PIXEL_LIMIT
|
||||
>> [callback, dbClient, userId, username, faceId, x, y, color](const Result& r) {
|
||||
if (r.empty()) {
|
||||
// Daily limit reached
|
||||
callback(jsonError("Daily pixel limit reached (1000/day)"));
|
||||
return;
|
||||
}
|
||||
|
||||
int pixelsPlaced = r[0]["pixels_placed"].as<int>();
|
||||
int remaining = PyramidController::DAILY_PIXEL_LIMIT - pixelsPlaced;
|
||||
|
||||
// Insert/update the pixel
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_pixels (face_id, x, y, color, placed_by, placed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (face_id, x, y)
|
||||
DO UPDATE SET color = $4, placed_by = $5, placed_at = CURRENT_TIMESTAMP
|
||||
)"
|
||||
<< faceId << x << y << color << userId
|
||||
>> [callback, dbClient, userId, username, faceId, x, y, color, remaining](const Result&) {
|
||||
// Record in history
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_pixel_history (face_id, x, y, color, placed_by, placed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
)"
|
||||
<< faceId << x << y << color << userId
|
||||
>> [callback, userId, username, faceId, x, y, color, remaining](const Result&) {
|
||||
// Broadcast to WebSocket clients
|
||||
PyramidWebSocketController::broadcastPixelUpdate(
|
||||
faceId, x, y, color, userId, username);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["remainingToday"] = remaining;
|
||||
resp["pixel"]["faceId"] = faceId;
|
||||
resp["pixel"]["x"] = x;
|
||||
resp["pixel"]["y"] = y;
|
||||
resp["pixel"]["color"] = color;
|
||||
resp["pixel"]["placedBy"] = username;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "record pixel history");
|
||||
}
|
||||
>> DB_ERROR(callback, "place pixel");
|
||||
}
|
||||
>> DB_ERROR(callback, "check daily limit");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in placePixel: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidController::getPixelInfo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceIdStr,
|
||||
const std::string &xStr,
|
||||
const std::string &yStr) {
|
||||
try {
|
||||
PARSE_ID(faceId, faceIdStr, callback);
|
||||
PARSE_ID(x, xStr, callback);
|
||||
PARSE_ID(y, yStr, callback);
|
||||
|
||||
if (!isValidPixelPosition(static_cast<int>(faceId), static_cast<int>(x), static_cast<int>(y))) {
|
||||
callback(jsonError("Invalid pixel position"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << R"(
|
||||
SELECT p.color, p.placed_at, u.id as user_id, u.username, u.user_color, u.avatar_url
|
||||
FROM pyramid_pixels p
|
||||
JOIN users u ON p.placed_by = u.id
|
||||
WHERE p.face_id = $1 AND p.x = $2 AND p.y = $3
|
||||
)"
|
||||
<< static_cast<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
|
||||
>> [callback, faceId, x, y](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["faceId"] = static_cast<int>(faceId);
|
||||
resp["x"] = static_cast<int>(x);
|
||||
resp["y"] = static_cast<int>(y);
|
||||
|
||||
if (r.empty()) {
|
||||
resp["isEmpty"] = true;
|
||||
} else {
|
||||
const auto& row = r[0];
|
||||
resp["isEmpty"] = false;
|
||||
resp["color"] = row["color"].as<std::string>();
|
||||
resp["placedAt"] = row["placed_at"].as<std::string>();
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
resp["user"]["username"] = row["username"].as<std::string>();
|
||||
resp["user"]["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
||||
resp["user"]["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get pixel info");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getPixelInfo: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== User Limits Methods ====================
|
||||
|
||||
void PyramidController::getLimits(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
CHECK_AUTH(user, callback);
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT pixels_placed FROM pyramid_daily_limits WHERE user_id = $1 AND date = CURRENT_DATE"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["dailyLimit"] = PyramidController::DAILY_PIXEL_LIMIT;
|
||||
|
||||
if (r.empty()) {
|
||||
resp["pixelsPlacedToday"] = 0;
|
||||
resp["remainingToday"] = PyramidController::DAILY_PIXEL_LIMIT;
|
||||
} else {
|
||||
int placed = r[0]["pixels_placed"].as<int>();
|
||||
resp["pixelsPlacedToday"] = placed;
|
||||
resp["remainingToday"] = PyramidController::DAILY_PIXEL_LIMIT - placed;
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get limits");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getLimits: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Moderation Methods ====================
|
||||
|
||||
void PyramidController::rollbackPixel(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
CHECK_AUTH(user, callback);
|
||||
|
||||
// Only admins and moderators can rollback
|
||||
if (!user.isAdmin && !user.isModerator) {
|
||||
callback(jsonError("Moderator access required", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON body"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json->isMember("faceId") || !json->isMember("x") || !json->isMember("y")) {
|
||||
callback(jsonError("Missing required fields: faceId, x, y"));
|
||||
return;
|
||||
}
|
||||
|
||||
int faceId = (*json)["faceId"].asInt();
|
||||
int x = (*json)["x"].asInt();
|
||||
int y = (*json)["y"].asInt();
|
||||
|
||||
if (!isValidPixelPosition(faceId, x, y)) {
|
||||
callback(jsonError("Invalid pixel position"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
int64_t moderatorId = user.id;
|
||||
std::string moderatorName = user.username;
|
||||
|
||||
// Find the previous state (second most recent non-rolled-back entry)
|
||||
*dbClient << R"(
|
||||
SELECT id, color, placed_by FROM pyramid_pixel_history
|
||||
WHERE face_id = $1 AND x = $2 AND y = $3 AND rolled_back = false
|
||||
ORDER BY placed_at DESC
|
||||
LIMIT 2
|
||||
)"
|
||||
<< faceId << x << y
|
||||
>> [callback, dbClient, moderatorId, moderatorName, faceId, x, y](const Result& r) {
|
||||
if (r.size() < 1) {
|
||||
callback(jsonError("No pixel found at this position"));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t currentHistoryId = r[0]["id"].as<int64_t>();
|
||||
|
||||
if (r.size() < 2) {
|
||||
// No previous state, delete the pixel entirely
|
||||
*dbClient << "DELETE FROM pyramid_pixels WHERE face_id = $1 AND x = $2 AND y = $3"
|
||||
<< faceId << x << y
|
||||
>> [callback, dbClient, currentHistoryId, moderatorId, moderatorName, faceId, x, y](const Result&) {
|
||||
// Mark as rolled back
|
||||
*dbClient << R"(
|
||||
UPDATE pyramid_pixel_history
|
||||
SET rolled_back = true, rolled_back_by = $1, rolled_back_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
)"
|
||||
<< moderatorId << currentHistoryId
|
||||
>> [callback, moderatorName, faceId, x, y](const Result&) {
|
||||
// Broadcast rollback (empty means deleted)
|
||||
PyramidWebSocketController::broadcastRollback(
|
||||
faceId, x, y, "", moderatorName);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Pixel deleted (no previous state)";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "mark rolled back");
|
||||
}
|
||||
>> DB_ERROR(callback, "delete pixel");
|
||||
} else {
|
||||
// Restore previous state
|
||||
std::string prevColor = r[1]["color"].as<std::string>();
|
||||
int64_t prevUserId = r[1]["placed_by"].as<int64_t>();
|
||||
|
||||
*dbClient << R"(
|
||||
UPDATE pyramid_pixels
|
||||
SET color = $1, placed_by = $2, placed_at = CURRENT_TIMESTAMP
|
||||
WHERE face_id = $3 AND x = $4 AND y = $5
|
||||
)"
|
||||
<< prevColor << prevUserId << faceId << x << y
|
||||
>> [callback, dbClient, currentHistoryId, moderatorId, moderatorName, faceId, x, y, prevColor](const Result&) {
|
||||
// Mark current as rolled back
|
||||
*dbClient << R"(
|
||||
UPDATE pyramid_pixel_history
|
||||
SET rolled_back = true, rolled_back_by = $1, rolled_back_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
)"
|
||||
<< moderatorId << currentHistoryId
|
||||
>> [callback, moderatorName, faceId, x, y, prevColor](const Result&) {
|
||||
// Broadcast rollback
|
||||
PyramidWebSocketController::broadcastRollback(
|
||||
faceId, x, y, prevColor, moderatorName);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Pixel rolled back to previous state";
|
||||
resp["newColor"] = prevColor;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "mark rolled back");
|
||||
}
|
||||
>> DB_ERROR(callback, "restore pixel");
|
||||
}
|
||||
}
|
||||
>> DB_ERROR(callback, "find pixel history");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in rollbackPixel: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidController::getPixelHistory(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceIdStr,
|
||||
const std::string &xStr,
|
||||
const std::string &yStr) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
CHECK_AUTH(user, callback);
|
||||
|
||||
// Only admins and moderators can view full history
|
||||
if (!user.isAdmin && !user.isModerator) {
|
||||
callback(jsonError("Moderator access required", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
PARSE_ID(faceId, faceIdStr, callback);
|
||||
PARSE_ID(x, xStr, callback);
|
||||
PARSE_ID(y, yStr, callback);
|
||||
|
||||
if (!isValidPixelPosition(static_cast<int>(faceId), static_cast<int>(x), static_cast<int>(y))) {
|
||||
callback(jsonError("Invalid pixel position"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << R"(
|
||||
SELECT h.id, h.color, h.placed_at, h.rolled_back, h.rolled_back_at,
|
||||
u.id as user_id, u.username, u.user_color,
|
||||
rb.username as rolled_back_by_username
|
||||
FROM pyramid_pixel_history h
|
||||
JOIN users u ON h.placed_by = u.id
|
||||
LEFT JOIN users rb ON h.rolled_back_by = rb.id
|
||||
WHERE h.face_id = $1 AND h.x = $2 AND h.y = $3
|
||||
ORDER BY h.placed_at DESC
|
||||
LIMIT 50
|
||||
)"
|
||||
<< static_cast<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
|
||||
>> [callback, faceId, x, y](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["faceId"] = static_cast<int>(faceId);
|
||||
resp["x"] = static_cast<int>(x);
|
||||
resp["y"] = static_cast<int>(y);
|
||||
resp["history"] = Json::Value(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value entry;
|
||||
entry["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
entry["color"] = row["color"].as<std::string>();
|
||||
entry["placedAt"] = row["placed_at"].as<std::string>();
|
||||
entry["rolledBack"] = row["rolled_back"].as<bool>();
|
||||
entry["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
entry["username"] = row["username"].as<std::string>();
|
||||
entry["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
||||
|
||||
if (row["rolled_back"].as<bool>()) {
|
||||
entry["rolledBackAt"] = row["rolled_back_at"].as<std::string>();
|
||||
entry["rolledBackBy"] = row["rolled_back_by_username"].isNull() ? "" : row["rolled_back_by_username"].as<std::string>();
|
||||
}
|
||||
|
||||
resp["history"].append(entry);
|
||||
}
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get pixel history");
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in getPixelHistory: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WebSocket Controller ====================
|
||||
|
||||
void PyramidWebSocketController::handleNewConnection(const HttpRequestPtr &req,
|
||||
const WebSocketConnectionPtr &wsConnPtr) {
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
connections_[wsConnPtr] = ConnectionInfo{};
|
||||
|
||||
// Send welcome message
|
||||
Json::Value welcome;
|
||||
welcome["type"] = "welcome";
|
||||
welcome["message"] = "Connected to Pyramid World";
|
||||
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, welcome));
|
||||
|
||||
LOG_INFO << "Pyramid WebSocket connection established";
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr &wsConnPtr) {
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
connections_.erase(wsConnPtr);
|
||||
LOG_INFO << "Pyramid WebSocket connection closed";
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::handleNewMessage(const WebSocketConnectionPtr &wsConnPtr,
|
||||
std::string &&message,
|
||||
const WebSocketMessageType &type) {
|
||||
if (type != WebSocketMessageType::Text) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Json::Value msg;
|
||||
Json::CharReaderBuilder builder;
|
||||
std::istringstream stream(message);
|
||||
std::string errors;
|
||||
|
||||
if (!Json::parseFromStream(builder, stream, &msg, &errors)) {
|
||||
LOG_ERROR << "Failed to parse WebSocket message: " << errors;
|
||||
return;
|
||||
}
|
||||
|
||||
std::string msgType = msg["type"].asString();
|
||||
|
||||
if (msgType == "auth") {
|
||||
handleAuth(wsConnPtr, msg);
|
||||
} else if (msgType == "place_pixel") {
|
||||
handlePlacePixel(wsConnPtr, msg);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error handling WebSocket message: " << e.what();
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::handleAuth(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg) {
|
||||
std::string token = msg["token"].asString();
|
||||
|
||||
if (token.empty()) {
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "No token provided";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
return;
|
||||
}
|
||||
|
||||
UserInfo user;
|
||||
if (!AuthService::getInstance().validateToken(token, user) || user.id == 0) {
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "Invalid token";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
auto it = connections_.find(wsConnPtr);
|
||||
if (it != connections_.end()) {
|
||||
it->second.userId = user.id;
|
||||
it->second.username = user.username;
|
||||
it->second.isAuthenticated = true;
|
||||
it->second.isModerator = user.isModerator;
|
||||
it->second.isAdmin = user.isAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
// Get remaining pixels for today
|
||||
auto dbClient = drogon::app().getDbClient();
|
||||
*dbClient << "SELECT pixels_placed FROM pyramid_daily_limits WHERE user_id = $1 AND date = CURRENT_DATE"
|
||||
<< user.id
|
||||
>> [wsConnPtr](const Result& r) {
|
||||
int remaining = 1000;
|
||||
if (!r.empty()) {
|
||||
remaining = 1000 - r[0]["pixels_placed"].as<int>();
|
||||
}
|
||||
|
||||
Json::Value authSuccess;
|
||||
authSuccess["type"] = "auth_success";
|
||||
authSuccess["remainingPixels"] = remaining;
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, authSuccess));
|
||||
}
|
||||
>> [](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get daily limit: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::handlePlacePixel(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg) {
|
||||
ConnectionInfo connInfo;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
auto it = connections_.find(wsConnPtr);
|
||||
if (it == connections_.end() || !it->second.isAuthenticated) {
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "Not authenticated";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
return;
|
||||
}
|
||||
connInfo = it->second;
|
||||
}
|
||||
|
||||
int faceId = msg["faceId"].asInt();
|
||||
int x = msg["x"].asInt();
|
||||
int y = msg["y"].asInt();
|
||||
std::string color = msg["color"].asString();
|
||||
|
||||
// Validate and uppercase color
|
||||
std::transform(color.begin(), color.end(), color.begin(), ::toupper);
|
||||
|
||||
// Use the REST endpoint logic for actual placement
|
||||
// This is a simplified version - in production, consider calling the same code
|
||||
auto dbClient = drogon::app().getDbClient();
|
||||
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_daily_limits (user_id, date, pixels_placed)
|
||||
VALUES ($1, CURRENT_DATE, 1)
|
||||
ON CONFLICT (user_id, date)
|
||||
DO UPDATE SET pixels_placed = pyramid_daily_limits.pixels_placed + 1
|
||||
WHERE pyramid_daily_limits.pixels_placed < 1000
|
||||
RETURNING pixels_placed
|
||||
)"
|
||||
<< connInfo.userId << 1000
|
||||
>> [wsConnPtr, dbClient, connInfo, faceId, x, y, color](const Result& r) {
|
||||
if (r.empty()) {
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "Daily limit reached";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
return;
|
||||
}
|
||||
|
||||
int remaining = 1000 - r[0]["pixels_placed"].as<int>();
|
||||
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_pixels (face_id, x, y, color, placed_by, placed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (face_id, x, y)
|
||||
DO UPDATE SET color = $4, placed_by = $5, placed_at = CURRENT_TIMESTAMP
|
||||
)"
|
||||
<< faceId << x << y << color << connInfo.userId
|
||||
>> [wsConnPtr, dbClient, connInfo, faceId, x, y, color, remaining](const Result&) {
|
||||
*dbClient << R"(
|
||||
INSERT INTO pyramid_pixel_history (face_id, x, y, color, placed_by, placed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
)"
|
||||
<< faceId << x << y << color << connInfo.userId
|
||||
>> [wsConnPtr, connInfo, faceId, x, y, color, remaining](const Result&) {
|
||||
// Broadcast to all clients
|
||||
broadcastPixelUpdate(faceId, x, y, color, connInfo.userId, connInfo.username);
|
||||
|
||||
// Send confirmation to the placing client
|
||||
Json::Value confirm;
|
||||
confirm["type"] = "pixel_placed";
|
||||
confirm["remainingPixels"] = remaining;
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, confirm));
|
||||
}
|
||||
>> [](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to record history: " << e.base().what();
|
||||
};
|
||||
}
|
||||
>> [wsConnPtr](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to place pixel: " << e.base().what();
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "Failed to place pixel";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
};
|
||||
}
|
||||
>> [wsConnPtr](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to check limit: " << e.base().what();
|
||||
Json::Value error;
|
||||
error["type"] = "error";
|
||||
error["message"] = "Database error";
|
||||
Json::StreamWriterBuilder builder;
|
||||
wsConnPtr->send(Json::writeString(builder, error));
|
||||
};
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::broadcastPixelUpdate(int faceId, int x, int y,
|
||||
const std::string &color,
|
||||
int64_t userId,
|
||||
const std::string &username) {
|
||||
Json::Value msg;
|
||||
msg["type"] = "pixel_update";
|
||||
msg["faceId"] = faceId;
|
||||
msg["x"] = x;
|
||||
msg["y"] = y;
|
||||
msg["color"] = color;
|
||||
msg["userId"] = static_cast<Json::Int64>(userId);
|
||||
msg["username"] = username;
|
||||
|
||||
Json::StreamWriterBuilder builder;
|
||||
std::string msgStr = Json::writeString(builder, msg);
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
for (const auto& [conn, info] : connections_) {
|
||||
conn->send(msgStr);
|
||||
}
|
||||
}
|
||||
|
||||
void PyramidWebSocketController::broadcastRollback(int faceId, int x, int y,
|
||||
const std::string &newColor,
|
||||
const std::string &moderator) {
|
||||
Json::Value msg;
|
||||
msg["type"] = "rollback";
|
||||
msg["faceId"] = faceId;
|
||||
msg["x"] = x;
|
||||
msg["y"] = y;
|
||||
msg["newColor"] = newColor;
|
||||
msg["moderator"] = moderator;
|
||||
|
||||
Json::StreamWriterBuilder builder;
|
||||
std::string msgStr = Json::writeString(builder, msg);
|
||||
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
for (const auto& [conn, info] : connections_) {
|
||||
conn->send(msgStr);
|
||||
}
|
||||
}
|
||||
106
backend/src/controllers/PyramidController.h
Normal file
106
backend/src/controllers/PyramidController.h
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include <drogon/WebSocketController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class PyramidController : public HttpController<PyramidController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// Canvas state
|
||||
ADD_METHOD_TO(PyramidController::getState, "/api/pyramid/state", Get);
|
||||
ADD_METHOD_TO(PyramidController::getFaceState, "/api/pyramid/face/{1}", Get);
|
||||
ADD_METHOD_TO(PyramidController::getColors, "/api/pyramid/colors", Get);
|
||||
|
||||
// Pixel operations
|
||||
ADD_METHOD_TO(PyramidController::placePixel, "/api/pyramid/pixel", Post);
|
||||
ADD_METHOD_TO(PyramidController::getPixelInfo, "/api/pyramid/pixel/{1}/{2}/{3}", Get);
|
||||
|
||||
// User limits
|
||||
ADD_METHOD_TO(PyramidController::getLimits, "/api/pyramid/limits", Get);
|
||||
|
||||
// Moderation (admin/moderator only)
|
||||
ADD_METHOD_TO(PyramidController::rollbackPixel, "/api/pyramid/rollback", Post);
|
||||
ADD_METHOD_TO(PyramidController::getPixelHistory, "/api/pyramid/history/{1}/{2}/{3}", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
// Canvas state methods
|
||||
void getState(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
void getFaceState(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceId);
|
||||
void getColors(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Pixel operation methods
|
||||
void placePixel(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
void getPixelInfo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceId,
|
||||
const std::string &x,
|
||||
const std::string &y);
|
||||
|
||||
// User limits methods
|
||||
void getLimits(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Moderation methods
|
||||
void rollbackPixel(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
void getPixelHistory(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &faceId,
|
||||
const std::string &x,
|
||||
const std::string &y);
|
||||
|
||||
private:
|
||||
static const int DAILY_PIXEL_LIMIT = 1000;
|
||||
static const int FACE_SIZE = 200;
|
||||
|
||||
bool isValidPixelPosition(int faceId, int x, int y);
|
||||
bool isValidColor(const std::string &color);
|
||||
};
|
||||
|
||||
// WebSocket controller for real-time pixel updates
|
||||
class PyramidWebSocketController : public WebSocketController<PyramidWebSocketController> {
|
||||
public:
|
||||
void handleNewMessage(const WebSocketConnectionPtr &wsConnPtr,
|
||||
std::string &&message,
|
||||
const WebSocketMessageType &type) override;
|
||||
void handleNewConnection(const HttpRequestPtr &req,
|
||||
const WebSocketConnectionPtr &wsConnPtr) override;
|
||||
void handleConnectionClosed(const WebSocketConnectionPtr &wsConnPtr) override;
|
||||
|
||||
// Static method to broadcast pixel updates to all connected clients
|
||||
static void broadcastPixelUpdate(int faceId, int x, int y,
|
||||
const std::string &color,
|
||||
int64_t userId,
|
||||
const std::string &username);
|
||||
|
||||
// Static method to broadcast rollback to all connected clients
|
||||
static void broadcastRollback(int faceId, int x, int y,
|
||||
const std::string &newColor,
|
||||
const std::string &moderator);
|
||||
|
||||
WS_PATH_LIST_BEGIN
|
||||
WS_PATH_ADD("/ws/pyramid");
|
||||
WS_PATH_LIST_END
|
||||
|
||||
private:
|
||||
struct ConnectionInfo {
|
||||
int64_t userId = 0;
|
||||
std::string username;
|
||||
bool isAuthenticated = false;
|
||||
bool isModerator = false;
|
||||
bool isAdmin = false;
|
||||
};
|
||||
|
||||
static std::unordered_map<WebSocketConnectionPtr, ConnectionInfo> connections_;
|
||||
static std::mutex connectionsMutex_;
|
||||
|
||||
void handleAuth(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg);
|
||||
void handlePlacePixel(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg);
|
||||
};
|
||||
|
|
@ -1290,4 +1290,111 @@ BEGIN
|
|||
) THEN
|
||||
ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE;
|
||||
END IF;
|
||||
END $$;
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- PYRAMID PIXEL WORLD (Collaborative Canvas)
|
||||
-- ============================================
|
||||
|
||||
-- Pyramid world configuration (singleton)
|
||||
CREATE TABLE IF NOT EXISTS pyramid_world (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
name VARCHAR(100) DEFAULT 'The Pyramid',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
total_pixels_placed BIGINT DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default pyramid world row
|
||||
INSERT INTO pyramid_world (id, name, is_active)
|
||||
VALUES (1, 'The Pyramid', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Trigger for pyramid_world updated_at
|
||||
DROP TRIGGER IF EXISTS update_pyramid_world_updated_at ON pyramid_world;
|
||||
CREATE TRIGGER update_pyramid_world_updated_at BEFORE UPDATE ON pyramid_world
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Current pixel state (latest placement wins)
|
||||
-- Face IDs: 0 = Base (square 200x200), 1-4 = North/East/South/West triangular faces
|
||||
CREATE TABLE IF NOT EXISTS pyramid_pixels (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
face_id SMALLINT NOT NULL CHECK (face_id >= 0 AND face_id <= 4),
|
||||
x SMALLINT NOT NULL CHECK (x >= 0 AND x < 200),
|
||||
y SMALLINT NOT NULL CHECK (y >= 0 AND y < 200),
|
||||
color VARCHAR(7) NOT NULL, -- Hex color #RRGGBB
|
||||
placed_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
placed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(face_id, x, y)
|
||||
);
|
||||
|
||||
-- Create indexes for pyramid_pixels
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_face ON pyramid_pixels(face_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_coords ON pyramid_pixels(face_id, x, y);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_user ON pyramid_pixels(placed_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_placed_at ON pyramid_pixels(placed_at DESC);
|
||||
|
||||
-- Pixel history (append-only for rollback and lookup)
|
||||
CREATE TABLE IF NOT EXISTS pyramid_pixel_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
face_id SMALLINT NOT NULL CHECK (face_id >= 0 AND face_id <= 4),
|
||||
x SMALLINT NOT NULL CHECK (x >= 0 AND x < 200),
|
||||
y SMALLINT NOT NULL CHECK (y >= 0 AND y < 200),
|
||||
color VARCHAR(7) NOT NULL,
|
||||
placed_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
placed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
rolled_back BOOLEAN DEFAULT false,
|
||||
rolled_back_by BIGINT REFERENCES users(id),
|
||||
rolled_back_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create indexes for pyramid_pixel_history
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_history_coords ON pyramid_pixel_history(face_id, x, y);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_history_user ON pyramid_pixel_history(placed_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_history_placed_at ON pyramid_pixel_history(placed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_history_active ON pyramid_pixel_history(rolled_back) WHERE rolled_back = false;
|
||||
|
||||
-- Daily pixel limits per user (resets at midnight UTC)
|
||||
CREATE TABLE IF NOT EXISTS pyramid_daily_limits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
pixels_placed INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
-- Create index for pyramid_daily_limits
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_daily_user_date ON pyramid_daily_limits(user_id, date);
|
||||
|
||||
-- 16-color palette (r/place style)
|
||||
CREATE TABLE IF NOT EXISTS pyramid_colors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
color VARCHAR(7) NOT NULL UNIQUE,
|
||||
name VARCHAR(50),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Insert default 16-color palette
|
||||
INSERT INTO pyramid_colors (color, name, sort_order) VALUES
|
||||
('#FFFFFF', 'White', 1),
|
||||
('#E4E4E4', 'Light Gray', 2),
|
||||
('#888888', 'Gray', 3),
|
||||
('#222222', 'Black', 4),
|
||||
('#FFA7D1', 'Pink', 5),
|
||||
('#E50000', 'Red', 6),
|
||||
('#E59500', 'Orange', 7),
|
||||
('#A06A42', 'Brown', 8),
|
||||
('#E5D900', 'Yellow', 9),
|
||||
('#94E044', 'Lime', 10),
|
||||
('#02BE01', 'Green', 11),
|
||||
('#00D3DD', 'Cyan', 12),
|
||||
('#0083C7', 'Blue', 13),
|
||||
('#0000EA', 'Dark Blue', 14),
|
||||
('#CF6EE4', 'Magenta', 15),
|
||||
('#820080', 'Purple', 16)
|
||||
ON CONFLICT (color) DO NOTHING;
|
||||
|
||||
-- Create index for pyramid_colors
|
||||
CREATE INDEX IF NOT EXISTS idx_pyramid_colors_sort ON pyramid_colors(sort_order) WHERE is_active = true;
|
||||
|
|
@ -25,12 +25,14 @@
|
|||
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||
"chess.js": "^1.0.0-beta.8",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/three": "^0.160.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"foliate-js": "^1.0.1",
|
||||
"hls.js": "^1.6.7",
|
||||
"marked": "^17.0.1",
|
||||
"openpgp": "^6.0.0-alpha.0",
|
||||
"ovenplayer": "^0.10.43"
|
||||
"ovenplayer": "^0.10.43",
|
||||
"three": "^0.160.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
89
frontend/src/lib/components/pyramid/ColorPalette.svelte
Normal file
89
frontend/src/lib/components/pyramid/ColorPalette.svelte
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<script>
|
||||
import { pyramidStore, colorPalette, selectedColor } from '$lib/stores/pyramid';
|
||||
|
||||
export let selected = '#E50000';
|
||||
|
||||
function selectColor(color) {
|
||||
selected = color;
|
||||
pyramidStore.setSelectedColor(color);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="color-palette">
|
||||
<h3>Colors</h3>
|
||||
<div class="colors-grid">
|
||||
{#each $colorPalette as colorItem}
|
||||
<button
|
||||
class="color-swatch"
|
||||
class:selected={selected === colorItem.color}
|
||||
style="background-color: {colorItem.color}"
|
||||
title={colorItem.name}
|
||||
on:click={() => selectColor(colorItem.color)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="selected-color">
|
||||
<div class="preview" style="background-color: {selected}" />
|
||||
<span>{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.color-palette {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.colors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-swatch.selected {
|
||||
border-color: #fff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.selected-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.selected-color span {
|
||||
font-family: monospace;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
236
frontend/src/lib/components/pyramid/PixelInfo.svelte
Normal file
236
frontend/src/lib/components/pyramid/PixelInfo.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<script>
|
||||
import { pyramidStore, FACE_NAMES } from '$lib/stores/pyramid';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let selectedPixel = null;
|
||||
let pixelInfo = null;
|
||||
let loading = false;
|
||||
let error = null;
|
||||
|
||||
const unsubscribe = pyramidStore.subscribe(state => {
|
||||
if (state.selectedPixel !== selectedPixel) {
|
||||
selectedPixel = state.selectedPixel;
|
||||
if (selectedPixel) {
|
||||
loadPixelInfo(selectedPixel);
|
||||
} else {
|
||||
pixelInfo = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(unsubscribe);
|
||||
|
||||
async function loadPixelInfo(pixel) {
|
||||
if (!pixel) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/pyramid/pixel/${pixel.faceId}/${pixel.x}/${pixel.y}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error('Failed to load pixel info');
|
||||
|
||||
const data = await res.json();
|
||||
pixelInfo = data;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
pixelInfo = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
pyramidStore.setSelectedPixel(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pixel-info">
|
||||
<h3>Pixel Info</h3>
|
||||
|
||||
{#if !selectedPixel}
|
||||
<p class="hint">Click a pixel to see who placed it</p>
|
||||
{:else if loading}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if pixelInfo}
|
||||
<div class="info-content">
|
||||
<div class="position">
|
||||
<strong>{FACE_NAMES[pixelInfo.faceId]}</strong>
|
||||
<span>({pixelInfo.x}, {pixelInfo.y})</span>
|
||||
</div>
|
||||
|
||||
{#if pixelInfo.isEmpty}
|
||||
<p class="empty">No pixel placed here</p>
|
||||
{:else}
|
||||
<div class="color-display">
|
||||
<div class="color-preview" style="background-color: {pixelInfo.color}" />
|
||||
<span class="color-code">{pixelInfo.color}</span>
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
{#if pixelInfo.user?.avatarUrl}
|
||||
<img src={pixelInfo.user.avatarUrl} alt="" class="avatar" />
|
||||
{:else}
|
||||
<div class="avatar placeholder" />
|
||||
{/if}
|
||||
<div class="user-details">
|
||||
<a
|
||||
href="/profile/{pixelInfo.user?.username}"
|
||||
class="username"
|
||||
style="color: {pixelInfo.user?.userColor || '#888'}"
|
||||
>
|
||||
{pixelInfo.user?.username || 'Unknown'}
|
||||
</a>
|
||||
<span class="time">{formatDate(pixelInfo.placedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="close-btn" on:click={clearSelection}>Clear</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pixel-info {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e50000;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.position span {
|
||||
color: #888;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.color-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.color-code {
|
||||
font-family: monospace;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar.placeholder {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.username {
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 6px 12px;
|
||||
background: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #444;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
384
frontend/src/lib/components/pyramid/PyramidCanvas.svelte
Normal file
384
frontend/src/lib/components/pyramid/PyramidCanvas.svelte
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
<script>
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { pyramidStore, FACE_SIZE, FACE_NAMES } from '$lib/stores/pyramid';
|
||||
|
||||
export let selectedColor = '#E50000';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let container;
|
||||
let scene, camera, renderer, controls;
|
||||
let pyramidGroup;
|
||||
let faceMeshes = [];
|
||||
let faceTextures = [];
|
||||
let raycaster, mouse;
|
||||
let animationId;
|
||||
|
||||
// Track if we're hovering over a face
|
||||
let hoveredFace = null;
|
||||
let hoveredPixel = null;
|
||||
|
||||
// Unsubscribe function for store
|
||||
let unsubscribePyramid;
|
||||
|
||||
onMount(() => {
|
||||
initScene();
|
||||
createPyramid();
|
||||
setupControls();
|
||||
setupRaycasting();
|
||||
animate();
|
||||
|
||||
// Subscribe to store updates for real-time pixel changes
|
||||
unsubscribePyramid = pyramidStore.subscribe(state => {
|
||||
if (!state.loading) {
|
||||
updateAllTextures(state.faces);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (unsubscribePyramid) {
|
||||
unsubscribePyramid();
|
||||
}
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
|
||||
// Cleanup Three.js resources
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
}
|
||||
faceMeshes.forEach(mesh => {
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
});
|
||||
faceTextures.forEach(texture => {
|
||||
texture.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function initScene() {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
|
||||
const aspect = container.clientWidth / container.clientHeight;
|
||||
camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
|
||||
camera.position.set(250, 200, 250);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
|
||||
directionalLight.position.set(1, 1, 1);
|
||||
scene.add(directionalLight);
|
||||
|
||||
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
directionalLight2.position.set(-1, 0.5, -1);
|
||||
scene.add(directionalLight2);
|
||||
|
||||
// Grid helper for reference
|
||||
const gridHelper = new THREE.GridHelper(400, 20, 0x222222, 0x111111);
|
||||
gridHelper.position.y = -1;
|
||||
scene.add(gridHelper);
|
||||
}
|
||||
|
||||
function createDataTexture(faceData) {
|
||||
const size = FACE_SIZE;
|
||||
const data = new Uint8Array(size * size * 4);
|
||||
|
||||
// Fill with default color (dark gray)
|
||||
for (let i = 0; i < size * size; i++) {
|
||||
data[i * 4] = 34; // R
|
||||
data[i * 4 + 1] = 34; // G
|
||||
data[i * 4 + 2] = 34; // B
|
||||
data[i * 4 + 3] = 255; // A
|
||||
}
|
||||
|
||||
// Apply pixel data from the face map
|
||||
if (faceData) {
|
||||
faceData.forEach((color, key) => {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const idx = (y * size + x) * 4;
|
||||
const rgb = hexToRgb(color);
|
||||
if (rgb) {
|
||||
data[idx] = rgb.r;
|
||||
data[idx + 1] = rgb.g;
|
||||
data[idx + 2] = rgb.b;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
|
||||
texture.needsUpdate = true;
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function createPyramid() {
|
||||
pyramidGroup = new THREE.Group();
|
||||
|
||||
// Create base (face 0) - square at y=0
|
||||
const baseGeometry = new THREE.PlaneGeometry(FACE_SIZE, FACE_SIZE);
|
||||
const baseTexture = createDataTexture(null);
|
||||
faceTextures[0] = baseTexture;
|
||||
|
||||
const baseMaterial = new THREE.MeshStandardMaterial({
|
||||
map: baseTexture,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const baseMesh = new THREE.Mesh(baseGeometry, baseMaterial);
|
||||
baseMesh.rotation.x = -Math.PI / 2;
|
||||
baseMesh.position.y = 0;
|
||||
baseMesh.userData = { faceId: 0 };
|
||||
pyramidGroup.add(baseMesh);
|
||||
faceMeshes[0] = baseMesh;
|
||||
|
||||
// Create four triangular faces (faces 1-4)
|
||||
const halfBase = FACE_SIZE / 2;
|
||||
const pyramidHeight = FACE_SIZE * 0.8;
|
||||
const apex = new THREE.Vector3(0, pyramidHeight, 0);
|
||||
|
||||
const baseCorners = [
|
||||
new THREE.Vector3(-halfBase, 0, -halfBase), // NW
|
||||
new THREE.Vector3(halfBase, 0, -halfBase), // NE
|
||||
new THREE.Vector3(halfBase, 0, halfBase), // SE
|
||||
new THREE.Vector3(-halfBase, 0, halfBase) // SW
|
||||
];
|
||||
|
||||
// Face indices: 1=North, 2=East, 3=South, 4=West
|
||||
const faceIndices = [
|
||||
[0, 1], // North: NW-NE
|
||||
[1, 2], // East: NE-SE
|
||||
[2, 3], // South: SE-SW
|
||||
[3, 0] // West: SW-NW
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [idx1, idx2] = faceIndices[i];
|
||||
const v1 = baseCorners[idx1];
|
||||
const v2 = baseCorners[idx2];
|
||||
|
||||
// Create triangle geometry
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const vertices = new Float32Array([
|
||||
apex.x, apex.y, apex.z,
|
||||
v1.x, v1.y, v1.z,
|
||||
v2.x, v2.y, v2.z
|
||||
]);
|
||||
|
||||
// UV mapping for triangular face
|
||||
const uvs = new Float32Array([
|
||||
0.5, 0, // apex at top center
|
||||
0, 1, // left corner at bottom left
|
||||
1, 1 // right corner at bottom right
|
||||
]);
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const texture = createDataTexture(null);
|
||||
faceTextures[i + 1] = texture;
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.userData = { faceId: i + 1 };
|
||||
pyramidGroup.add(mesh);
|
||||
faceMeshes[i + 1] = mesh;
|
||||
}
|
||||
|
||||
scene.add(pyramidGroup);
|
||||
}
|
||||
|
||||
function setupControls() {
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.screenSpacePanning = false;
|
||||
controls.minDistance = 100;
|
||||
controls.maxDistance = 500;
|
||||
controls.maxPolarAngle = Math.PI / 2 - 0.1;
|
||||
controls.target.set(0, 50, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function setupRaycasting() {
|
||||
raycaster = new THREE.Raycaster();
|
||||
mouse = new THREE.Vector2();
|
||||
|
||||
container.addEventListener('mousemove', onMouseMove);
|
||||
container.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(faceMeshes);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const intersect = intersects[0];
|
||||
const faceId = intersect.object.userData.faceId;
|
||||
const uv = intersect.uv;
|
||||
|
||||
if (uv) {
|
||||
const x = Math.floor(uv.x * FACE_SIZE);
|
||||
const y = Math.floor((1 - uv.y) * FACE_SIZE);
|
||||
|
||||
hoveredFace = faceId;
|
||||
hoveredPixel = { faceId, x, y };
|
||||
|
||||
dispatch('hover', { faceId, x, y });
|
||||
pyramidStore.setHoveredPixel({ faceId, x, y });
|
||||
}
|
||||
} else {
|
||||
hoveredFace = null;
|
||||
hoveredPixel = null;
|
||||
pyramidStore.setHoveredPixel(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
if (!hoveredPixel) return;
|
||||
|
||||
const { faceId, x, y } = hoveredPixel;
|
||||
|
||||
// Dispatch place event for parent to handle
|
||||
dispatch('place', { faceId, x, y, color: selectedColor });
|
||||
|
||||
// Also set as selected for info panel
|
||||
pyramidStore.setSelectedPixel({ faceId, x, y });
|
||||
}
|
||||
|
||||
function updateAllTextures(faces) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
updateFaceTexture(i, faces[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFaceTexture(faceId, faceData) {
|
||||
if (!faceTextures[faceId]) return;
|
||||
|
||||
const texture = faceTextures[faceId];
|
||||
const size = FACE_SIZE;
|
||||
|
||||
// Reset to default color
|
||||
for (let i = 0; i < size * size; i++) {
|
||||
texture.image.data[i * 4] = 34;
|
||||
texture.image.data[i * 4 + 1] = 34;
|
||||
texture.image.data[i * 4 + 2] = 34;
|
||||
}
|
||||
|
||||
// Apply pixel data
|
||||
if (faceData) {
|
||||
faceData.forEach((color, key) => {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const idx = (y * size + x) * 4;
|
||||
const rgb = hexToRgb(color);
|
||||
if (rgb) {
|
||||
texture.image.data[idx] = rgb.r;
|
||||
texture.image.data[idx + 1] = rgb.g;
|
||||
texture.image.data[idx + 2] = rgb.b;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Public method to update a single pixel (for real-time updates)
|
||||
export function updatePixel(faceId, x, y, color) {
|
||||
if (!faceTextures[faceId]) return;
|
||||
|
||||
const texture = faceTextures[faceId];
|
||||
const idx = (y * FACE_SIZE + x) * 4;
|
||||
const rgb = hexToRgb(color);
|
||||
|
||||
if (rgb) {
|
||||
texture.image.data[idx] = rgb.r;
|
||||
texture.image.data[idx + 1] = rgb.g;
|
||||
texture.image.data[idx + 2] = rgb.b;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
if (!container || !camera || !renderer) return;
|
||||
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pyramid-canvas" bind:this={container}>
|
||||
{#if hoveredPixel}
|
||||
<div class="hover-info">
|
||||
{FACE_NAMES[hoveredPixel.faceId]} ({hoveredPixel.x}, {hoveredPixel.y})
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pyramid-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pyramid-canvas :global(canvas) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.hover-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/lib/components/pyramid/UserStats.svelte
Normal file
118
frontend/src/lib/components/pyramid/UserStats.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<script>
|
||||
import { pyramidStore, remainingPixels, isConnected, DAILY_LIMIT } from '$lib/stores/pyramid';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let remaining = DAILY_LIMIT;
|
||||
let connected = false;
|
||||
|
||||
const unsubscribeRemaining = remainingPixels.subscribe(val => {
|
||||
remaining = val;
|
||||
});
|
||||
|
||||
const unsubscribeConnected = isConnected.subscribe(val => {
|
||||
connected = val;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeRemaining();
|
||||
unsubscribeConnected();
|
||||
});
|
||||
|
||||
$: percentage = (remaining / DAILY_LIMIT) * 100;
|
||||
$: barColor = remaining > 200 ? '#02BE01' : remaining > 50 ? '#E5D900' : '#E50000';
|
||||
</script>
|
||||
|
||||
<div class="user-stats">
|
||||
<div class="stat-header">
|
||||
<h3>Your Pixels</h3>
|
||||
<span class="status" class:connected>
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="remaining">
|
||||
<span class="count">{remaining}</span>
|
||||
<span class="label">/ {DAILY_LIMIT} remaining today</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {percentage}%; background-color: {barColor}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Resets at midnight UTC
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user-stats {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: rgba(2, 190, 1, 0.2);
|
||||
color: #02BE01;
|
||||
}
|
||||
|
||||
.remaining {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,12 +7,17 @@
|
|||
let animationId;
|
||||
let width, height;
|
||||
let hue = 0;
|
||||
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
||||
let phase = 'growing'; // 'growing' | 'shattering'
|
||||
let phaseStartTime = 0;
|
||||
let branches = []; // Active growing branch tips
|
||||
let crystalPoints = []; // All drawn points for shatter effect
|
||||
let shatterParticles = [];
|
||||
|
||||
// Collision detection grid
|
||||
let occupiedGrid = [];
|
||||
const GRID_CELL_SIZE = 4;
|
||||
let gridWidth, gridHeight;
|
||||
|
||||
const CONFIG = {
|
||||
seedCount: 5, // Number of initial seed points
|
||||
branchSpeed: 2, // Pixels per frame
|
||||
|
|
@ -20,9 +25,8 @@
|
|||
branchChance: 0.03, // Chance to spawn new branch per frame
|
||||
turnAngle: 0.3, // Max random turn per frame (radians)
|
||||
hueShiftSpeed: 0.2, // Color cycling speed
|
||||
maxPoints: 15000, // Max crystal points before shatter
|
||||
maxPoints: 15000, // Max crystal points (kept for reference)
|
||||
shatterDuration: 2500, // Milliseconds for shatter effect
|
||||
dissolveDuration: 2000, // Milliseconds for dissolve effect
|
||||
lineWidth: 2
|
||||
};
|
||||
|
||||
|
|
@ -33,6 +37,38 @@
|
|||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx = canvas.getContext('2d');
|
||||
initGrid();
|
||||
}
|
||||
|
||||
function initGrid() {
|
||||
gridWidth = Math.ceil(width / GRID_CELL_SIZE);
|
||||
gridHeight = Math.ceil(height / GRID_CELL_SIZE);
|
||||
occupiedGrid = Array(gridHeight).fill(null).map(() => Array(gridWidth).fill(false));
|
||||
}
|
||||
|
||||
function isOccupied(x, y) {
|
||||
const gx = Math.floor(x / GRID_CELL_SIZE);
|
||||
const gy = Math.floor(y / GRID_CELL_SIZE);
|
||||
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) return true;
|
||||
return occupiedGrid[gy][gx];
|
||||
}
|
||||
|
||||
function markOccupied(x, y) {
|
||||
const gx = Math.floor(x / GRID_CELL_SIZE);
|
||||
const gy = Math.floor(y / GRID_CELL_SIZE);
|
||||
if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) {
|
||||
occupiedGrid[gy][gx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getCoverage() {
|
||||
let filled = 0;
|
||||
for (let row of occupiedGrid) {
|
||||
for (let cell of row) {
|
||||
if (cell) filled++;
|
||||
}
|
||||
}
|
||||
return filled / (gridWidth * gridHeight);
|
||||
}
|
||||
|
||||
function initCrystal() {
|
||||
|
|
@ -42,6 +78,9 @@
|
|||
phase = 'growing';
|
||||
phaseStartTime = performance.now();
|
||||
|
||||
// Reset collision grid
|
||||
initGrid();
|
||||
|
||||
// Clear canvas
|
||||
if (ctx) {
|
||||
ctx.fillStyle = 'black';
|
||||
|
|
@ -74,8 +113,6 @@
|
|||
updateGrowing();
|
||||
} else if (phase === 'shattering') {
|
||||
updateShattering();
|
||||
} else if (phase === 'dissolving') {
|
||||
updateDissolving();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +137,15 @@
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check collision - kill branch if cell is already occupied
|
||||
if (isOccupied(newX, newY)) {
|
||||
branches.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark cell as occupied
|
||||
markOccupied(newX, newY);
|
||||
|
||||
// Store point for shatter effect
|
||||
crystalPoints.push({
|
||||
x: newX,
|
||||
|
|
@ -133,37 +179,42 @@
|
|||
generation: branch.generation + 1
|
||||
});
|
||||
}
|
||||
|
||||
// Chance to die (increases with age and generation)
|
||||
const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002;
|
||||
if (Math.random() < deathChance) {
|
||||
branches.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new branches
|
||||
branches.push(...newBranches);
|
||||
|
||||
// Check if we should shatter (too many points or no more branches)
|
||||
if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) {
|
||||
// Check if we should shatter (coverage threshold reached or no more branches)
|
||||
const coverage = getCoverage();
|
||||
if (coverage > 0.75 || (branches.length === 0 && crystalPoints.length > 100)) {
|
||||
startShatter();
|
||||
}
|
||||
|
||||
// Spawn new seeds occasionally if branches are dying off
|
||||
if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) {
|
||||
const x = Math.random() * width;
|
||||
const y = Math.random() * height;
|
||||
const branchCount = 2 + Math.floor(Math.random() * 3);
|
||||
for (let j = 0; j < branchCount; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
branches.push({
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
hue: Math.random() * 360,
|
||||
age: 0,
|
||||
generation: 0
|
||||
});
|
||||
// Spawn new seeds if branches are dying off and we haven't filled the screen
|
||||
if (branches.length < 10 && coverage < 0.7) {
|
||||
// Find an unoccupied spot for the new seed
|
||||
let attempts = 0;
|
||||
let x, y;
|
||||
do {
|
||||
x = Math.random() * width;
|
||||
y = Math.random() * height;
|
||||
attempts++;
|
||||
} while (isOccupied(x, y) && attempts < 50);
|
||||
|
||||
// Only spawn if we found an unoccupied spot
|
||||
if (!isOccupied(x, y)) {
|
||||
const branchCount = 2 + Math.floor(Math.random() * 3);
|
||||
for (let j = 0; j < branchCount; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
branches.push({
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
hue: Math.random() * 360,
|
||||
age: 0,
|
||||
generation: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -214,32 +265,7 @@
|
|||
}
|
||||
|
||||
if (elapsed >= CONFIG.shatterDuration) {
|
||||
phase = 'dissolving';
|
||||
phaseStartTime = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
function updateDissolving() {
|
||||
const elapsed = performance.now() - phaseStartTime;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const p of shatterParticles) {
|
||||
p.x += p.vx * 0.5;
|
||||
p.y += p.vy * 0.5;
|
||||
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
||||
|
||||
if (p.alpha > 0.01) {
|
||||
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed >= CONFIG.dissolveDuration) {
|
||||
initCrystal(); // Regrow
|
||||
initCrystal(); // Go directly to regrow
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
236
frontend/src/lib/pyramid/pyramidWebSocket.js
Normal file
236
frontend/src/lib/pyramid/pyramidWebSocket.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { pyramidStore } from '$lib/stores/pyramid';
|
||||
|
||||
class PyramidWebSocket {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.token = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000;
|
||||
this.maxReconnectDelay = 30000;
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
|
||||
async connect(token = null) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.token = token;
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/pyramid`;
|
||||
|
||||
console.log('[PyramidWebSocket] Connecting to:', wsUrl);
|
||||
pyramidStore.setConnected(false);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[PyramidWebSocket] Connected');
|
||||
pyramidStore.setConnected(true);
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Send auth token if available
|
||||
if (this.token) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.token }));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
console.error('[PyramidWebSocket] Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[PyramidWebSocket] Error:', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('[PyramidWebSocket] Disconnected');
|
||||
pyramidStore.setConnected(false);
|
||||
this.attemptReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PyramidWebSocket] Failed to create WebSocket:', error);
|
||||
pyramidStore.setConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'welcome':
|
||||
console.log('[PyramidWebSocket] Welcome:', data.message);
|
||||
break;
|
||||
|
||||
case 'auth_success':
|
||||
console.log('[PyramidWebSocket] Authenticated');
|
||||
if (data.remainingPixels !== undefined) {
|
||||
pyramidStore.setRemaining(data.remainingPixels);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pixel_update':
|
||||
// Another user placed a pixel
|
||||
pyramidStore.updatePixel(
|
||||
data.faceId,
|
||||
data.x,
|
||||
data.y,
|
||||
data.color
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pixel_placed':
|
||||
// Confirmation of our own pixel placement
|
||||
if (data.remainingPixels !== undefined) {
|
||||
pyramidStore.setRemaining(data.remainingPixels);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rollback':
|
||||
// Moderator rolled back a pixel
|
||||
pyramidStore.updatePixel(
|
||||
data.faceId,
|
||||
data.x,
|
||||
data.y,
|
||||
data.newColor || null
|
||||
);
|
||||
console.log(`[PyramidWebSocket] Pixel rolled back by ${data.moderator}`);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[PyramidWebSocket] Server error:', data.message);
|
||||
pyramidStore.setError(data.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[PyramidWebSocket] Unknown message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
placePixel(faceId, x, y, color) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('[PyramidWebSocket] Not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'place_pixel',
|
||||
faceId,
|
||||
x,
|
||||
y,
|
||||
color
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('[PyramidWebSocket] Max reconnect attempts reached');
|
||||
|
||||
// Try again after 60 seconds
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
}
|
||||
this.reconnectResetTimer = setTimeout(() => {
|
||||
console.log('[PyramidWebSocket] Resetting reconnect attempts');
|
||||
this.reconnectAttempts = 0;
|
||||
this.attemptReconnect();
|
||||
}, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.log(`[PyramidWebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(async () => {
|
||||
// Try to refresh token before reconnecting
|
||||
if (this.token) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
this.token = data.token;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PyramidWebSocket] Token refresh failed:', e);
|
||||
}
|
||||
}
|
||||
this.connect(this.token);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async manualReconnect(token = null) {
|
||||
console.log('[PyramidWebSocket] Manual reconnect');
|
||||
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Get fresh token if not provided
|
||||
let freshToken = token;
|
||||
if (!freshToken) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
freshToken = data.token;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PyramidWebSocket] Could not refresh token:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.token = freshToken;
|
||||
|
||||
// Close existing connection
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.connect(this.token);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.token = null;
|
||||
this.reconnectAttempts = 0;
|
||||
pyramidStore.setConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const pyramidWebSocket = new PyramidWebSocket();
|
||||
|
|
@ -11,6 +11,9 @@ let refreshInterval = null;
|
|||
let isRefreshing = false;
|
||||
let refreshPromise = null;
|
||||
let consecutiveFailures = 0;
|
||||
let lastVisibilityRefresh = 0;
|
||||
const VISIBILITY_REFRESH_COOLDOWN_MS = 60 * 1000; // 1 minute cooldown
|
||||
let visibilityHandlerSetup = false;
|
||||
|
||||
// Silent token refresh function with retry logic
|
||||
// Returns: { success: boolean, isAuthError: boolean }
|
||||
|
|
@ -90,6 +93,9 @@ export async function refreshAccessToken() {
|
|||
function startTokenRefresh() {
|
||||
if (!browser) return;
|
||||
|
||||
// Setup visibility handler to refresh when tab becomes visible
|
||||
setupVisibilityHandler();
|
||||
|
||||
// Clear any existing interval
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
|
|
@ -122,6 +128,24 @@ function stopTokenRefresh() {
|
|||
}
|
||||
}
|
||||
|
||||
// Setup visibility change handler to refresh token when tab becomes visible
|
||||
function setupVisibilityHandler() {
|
||||
if (!browser || visibilityHandlerSetup) return;
|
||||
visibilityHandlerSetup = true;
|
||||
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const now = Date.now();
|
||||
// Only refresh if enough time has passed since last refresh
|
||||
if (now - lastVisibilityRefresh > VISIBILITY_REFRESH_COOLDOWN_MS) {
|
||||
lastVisibilityRefresh = now;
|
||||
console.log('Tab became visible, refreshing token...');
|
||||
await refreshAccessToken();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
user: null,
|
||||
|
|
@ -136,11 +160,24 @@ function createAuthStore() {
|
|||
|
||||
// Use cookie-based auth - no localStorage tokens
|
||||
try {
|
||||
const response = await fetch('/api/user/me', {
|
||||
let response = await fetch('/api/user/me', {
|
||||
credentials: 'include', // Send cookies
|
||||
cache: 'no-store' // Always fetch fresh data
|
||||
});
|
||||
|
||||
// If access token expired, try to refresh before giving up
|
||||
if (response.status === 401) {
|
||||
console.log('Access token expired, attempting refresh...');
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
// Retry with new token
|
||||
response = await fetch('/api/user/me', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ user: data.user, loading: false });
|
||||
|
|
|
|||
174
frontend/src/lib/stores/pyramid.js
Normal file
174
frontend/src/lib/stores/pyramid.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
// Face configuration
|
||||
export const FACE_SIZE = 200;
|
||||
export const FACE_NAMES = ['Base', 'North', 'East', 'South', 'West'];
|
||||
export const DAILY_LIMIT = 1000;
|
||||
|
||||
function createPyramidStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
// Canvas state - 5 faces, each is a Map of "x,y" -> color
|
||||
faces: [new Map(), new Map(), new Map(), new Map(), new Map()],
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: null,
|
||||
// User limits
|
||||
remainingPixels: DAILY_LIMIT,
|
||||
pixelsPlacedToday: 0,
|
||||
// Selection state
|
||||
selectedColor: '#E50000',
|
||||
hoveredPixel: null,
|
||||
selectedPixel: null,
|
||||
// Color palette
|
||||
colors: [],
|
||||
// WebSocket connection status
|
||||
connected: false
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load initial canvas state from API
|
||||
async loadState() {
|
||||
update(state => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [stateRes, colorsRes, limitsRes] = await Promise.all([
|
||||
fetch('/api/pyramid/state', { credentials: 'include' }),
|
||||
fetch('/api/pyramid/colors', { credentials: 'include' }),
|
||||
fetch('/api/pyramid/limits', { credentials: 'include' })
|
||||
]);
|
||||
|
||||
if (!stateRes.ok) throw new Error('Failed to load canvas state');
|
||||
if (!colorsRes.ok) throw new Error('Failed to load colors');
|
||||
|
||||
const stateData = await stateRes.json();
|
||||
const colorsData = await colorsRes.json();
|
||||
const limitsData = limitsRes.ok ? await limitsRes.json() : null;
|
||||
|
||||
// Build face maps from pixel data
|
||||
const faces = [new Map(), new Map(), new Map(), new Map(), new Map()];
|
||||
for (const pixel of stateData.pixels || []) {
|
||||
const key = `${pixel.x},${pixel.y}`;
|
||||
faces[pixel.faceId].set(key, pixel.color);
|
||||
}
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
faces,
|
||||
colors: colorsData.colors || [],
|
||||
remainingPixels: limitsData?.remainingToday ?? DAILY_LIMIT,
|
||||
pixelsPlacedToday: limitsData?.pixelsPlacedToday ?? 0,
|
||||
loading: false,
|
||||
selectedColor: colorsData.colors?.[5]?.color || '#E50000'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load pyramid state:', error);
|
||||
update(state => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Update a single pixel (from WebSocket or local placement)
|
||||
updatePixel(faceId, x, y, color) {
|
||||
update(state => {
|
||||
const newFaces = [...state.faces];
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (color) {
|
||||
newFaces[faceId] = new Map(newFaces[faceId]);
|
||||
newFaces[faceId].set(key, color);
|
||||
} else {
|
||||
// Empty color means delete
|
||||
newFaces[faceId] = new Map(newFaces[faceId]);
|
||||
newFaces[faceId].delete(key);
|
||||
}
|
||||
|
||||
return { ...state, faces: newFaces };
|
||||
});
|
||||
},
|
||||
|
||||
// Set selected color
|
||||
setSelectedColor(color) {
|
||||
update(state => ({ ...state, selectedColor: color }));
|
||||
},
|
||||
|
||||
// Set hovered pixel
|
||||
setHoveredPixel(pixel) {
|
||||
update(state => ({ ...state, hoveredPixel: pixel }));
|
||||
},
|
||||
|
||||
// Set selected pixel (for info panel)
|
||||
setSelectedPixel(pixel) {
|
||||
update(state => ({ ...state, selectedPixel: pixel }));
|
||||
},
|
||||
|
||||
// Decrement remaining pixels after successful placement
|
||||
decrementRemaining() {
|
||||
update(state => ({
|
||||
...state,
|
||||
remainingPixels: Math.max(0, state.remainingPixels - 1),
|
||||
pixelsPlacedToday: state.pixelsPlacedToday + 1
|
||||
}));
|
||||
},
|
||||
|
||||
// Update remaining from server
|
||||
setRemaining(remaining) {
|
||||
update(state => ({
|
||||
...state,
|
||||
remainingPixels: remaining,
|
||||
pixelsPlacedToday: DAILY_LIMIT - remaining
|
||||
}));
|
||||
},
|
||||
|
||||
// Set connection status
|
||||
setConnected(connected) {
|
||||
update(state => ({ ...state, connected }));
|
||||
},
|
||||
|
||||
// Set error
|
||||
setError(error) {
|
||||
update(state => ({ ...state, error }));
|
||||
},
|
||||
|
||||
// Get pixel color from face
|
||||
getPixelColor(faceId, x, y) {
|
||||
let color = null;
|
||||
const unsubscribe = subscribe(state => {
|
||||
const key = `${x},${y}`;
|
||||
color = state.faces[faceId]?.get(key) || null;
|
||||
});
|
||||
unsubscribe();
|
||||
return color;
|
||||
},
|
||||
|
||||
// Reset store
|
||||
reset() {
|
||||
set({
|
||||
faces: [new Map(), new Map(), new Map(), new Map(), new Map()],
|
||||
loading: true,
|
||||
error: null,
|
||||
remainingPixels: DAILY_LIMIT,
|
||||
pixelsPlacedToday: 0,
|
||||
selectedColor: '#E50000',
|
||||
hoveredPixel: null,
|
||||
selectedPixel: null,
|
||||
colors: [],
|
||||
connected: false
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const pyramidStore = createPyramidStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const isLoading = derived(pyramidStore, $store => $store.loading);
|
||||
export const pyramidError = derived(pyramidStore, $store => $store.error);
|
||||
export const remainingPixels = derived(pyramidStore, $store => $store.remainingPixels);
|
||||
export const selectedColor = derived(pyramidStore, $store => $store.selectedColor);
|
||||
export const colorPalette = derived(pyramidStore, $store => $store.colors);
|
||||
export const isConnected = derived(pyramidStore, $store => $store.connected);
|
||||
|
|
@ -50,7 +50,8 @@
|
|||
if (state.user) {
|
||||
screensaver.init({
|
||||
screensaverEnabled: state.user.screensaverEnabled,
|
||||
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes
|
||||
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes,
|
||||
screensaverType: state.user.screensaverType
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -58,7 +59,8 @@
|
|||
if ($auth.user) {
|
||||
screensaver.init({
|
||||
screensaverEnabled: $auth.user.screensaverEnabled,
|
||||
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes
|
||||
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes,
|
||||
screensaverType: $auth.user.screensaverType
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -562,6 +564,13 @@
|
|||
</svg>
|
||||
Games
|
||||
</a>
|
||||
<a href="/pyramid" class="dropdown-item">
|
||||
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 22h20L12 2z"/>
|
||||
<path d="M12 2v20M2 22h20"/>
|
||||
</svg>
|
||||
World
|
||||
</a>
|
||||
<a href="/stats" class="dropdown-item">
|
||||
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 20V10M12 20V4M6 20v-6"/>
|
||||
|
|
|
|||
267
frontend/src/routes/pyramid/+page.svelte
Normal file
267
frontend/src/routes/pyramid/+page.svelte
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { pyramidStore, isLoading, pyramidError, selectedColor } from '$lib/stores/pyramid';
|
||||
import { pyramidWebSocket } from '$lib/pyramid/pyramidWebSocket';
|
||||
import PyramidCanvas from '$lib/components/pyramid/PyramidCanvas.svelte';
|
||||
import ColorPalette from '$lib/components/pyramid/ColorPalette.svelte';
|
||||
import PixelInfo from '$lib/components/pyramid/PixelInfo.svelte';
|
||||
import UserStats from '$lib/components/pyramid/UserStats.svelte';
|
||||
|
||||
let pyramidCanvas;
|
||||
let currentColor = '#E50000';
|
||||
let authLoaded = false;
|
||||
|
||||
// Subscribe to auth store
|
||||
const unsubscribeAuth = auth.subscribe(value => {
|
||||
if (!value.loading) {
|
||||
authLoaded = true;
|
||||
if (!value.user) {
|
||||
goto('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to selected color
|
||||
const unsubscribeColor = selectedColor.subscribe(val => {
|
||||
currentColor = val;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Wait for auth to load
|
||||
if (!authLoaded) {
|
||||
const checkAuth = setInterval(() => {
|
||||
if (!$auth.loading) {
|
||||
clearInterval(checkAuth);
|
||||
if (!$auth.user) {
|
||||
goto('/login');
|
||||
} else {
|
||||
initPyramid();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
} else if ($auth.user) {
|
||||
initPyramid();
|
||||
}
|
||||
});
|
||||
|
||||
async function initPyramid() {
|
||||
// Load initial state
|
||||
await pyramidStore.loadState();
|
||||
|
||||
// Connect WebSocket with auth token
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
await pyramidWebSocket.connect(data.token);
|
||||
}
|
||||
} else {
|
||||
// Try connecting without token (will be guest)
|
||||
await pyramidWebSocket.connect();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get auth token:', e);
|
||||
await pyramidWebSocket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeAuth();
|
||||
unsubscribeColor();
|
||||
pyramidWebSocket.disconnect();
|
||||
pyramidStore.reset();
|
||||
});
|
||||
|
||||
async function handlePlacePixel(event) {
|
||||
const { faceId, x, y, color } = event.detail;
|
||||
|
||||
// Optimistic update
|
||||
pyramidStore.updatePixel(faceId, x, y, color);
|
||||
pyramidStore.decrementRemaining();
|
||||
|
||||
// Send via WebSocket
|
||||
const success = pyramidWebSocket.placePixel(faceId, x, y, color);
|
||||
|
||||
if (!success) {
|
||||
// WebSocket not connected, fall back to REST API
|
||||
try {
|
||||
const res = await fetch('/api/pyramid/pixel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ faceId, x, y, color })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
pyramidStore.setError(data.error || 'Failed to place pixel');
|
||||
// Reload state to revert optimistic update
|
||||
pyramidStore.loadState();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (data.remainingToday !== undefined) {
|
||||
pyramidStore.setRemaining(data.remainingToday);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
pyramidStore.setError('Network error');
|
||||
pyramidStore.loadState();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Pyramid World - Realms</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="pyramid-page">
|
||||
<header class="pyramid-header">
|
||||
<h1>Pyramid World</h1>
|
||||
<p class="subtitle">Collaborative pixel art on a 3D pyramid</p>
|
||||
</header>
|
||||
|
||||
{#if $isLoading}
|
||||
<div class="loading-container">
|
||||
<div class="loading">Loading pyramid...</div>
|
||||
</div>
|
||||
{:else if $pyramidError}
|
||||
<div class="error-container">
|
||||
<div class="error">{$pyramidError}</div>
|
||||
<button on:click={() => pyramidStore.loadState()}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pyramid-layout">
|
||||
<div class="canvas-container">
|
||||
<PyramidCanvas
|
||||
bind:this={pyramidCanvas}
|
||||
selectedColor={currentColor}
|
||||
on:place={handlePlacePixel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<UserStats />
|
||||
<ColorPalette bind:selected={currentColor} />
|
||||
<PixelInfo />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="controls-hint">
|
||||
<p>Drag to rotate | Scroll to zoom | Click to place pixel</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pyramid-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.pyramid-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pyramid-header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
background: linear-gradient(135deg, #561D5E, #8b3a92);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #888;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #E50000;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
padding: 8px 16px;
|
||||
background: #561D5E;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: #6d2473;
|
||||
}
|
||||
|
||||
.pyramid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
background: #111;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 500px;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.controls-hint {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.controls-hint p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pyramid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar > :global(*) {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue