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);
|
||||||
|
};
|
||||||
|
|
@ -1291,3 +1291,110 @@ BEGIN
|
||||||
ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE;
|
ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE;
|
||||||
END IF;
|
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",
|
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||||
"chess.js": "^1.0.0-beta.8",
|
"chess.js": "^1.0.0-beta.8",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/three": "^0.160.0",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"foliate-js": "^1.0.1",
|
"foliate-js": "^1.0.1",
|
||||||
"hls.js": "^1.6.7",
|
"hls.js": "^1.6.7",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"openpgp": "^6.0.0-alpha.0",
|
"openpgp": "^6.0.0-alpha.0",
|
||||||
"ovenplayer": "^0.10.43"
|
"ovenplayer": "^0.10.43",
|
||||||
|
"three": "^0.160.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"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 animationId;
|
||||||
let width, height;
|
let width, height;
|
||||||
let hue = 0;
|
let hue = 0;
|
||||||
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
let phase = 'growing'; // 'growing' | 'shattering'
|
||||||
let phaseStartTime = 0;
|
let phaseStartTime = 0;
|
||||||
let branches = []; // Active growing branch tips
|
let branches = []; // Active growing branch tips
|
||||||
let crystalPoints = []; // All drawn points for shatter effect
|
let crystalPoints = []; // All drawn points for shatter effect
|
||||||
let shatterParticles = [];
|
let shatterParticles = [];
|
||||||
|
|
||||||
|
// Collision detection grid
|
||||||
|
let occupiedGrid = [];
|
||||||
|
const GRID_CELL_SIZE = 4;
|
||||||
|
let gridWidth, gridHeight;
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
seedCount: 5, // Number of initial seed points
|
seedCount: 5, // Number of initial seed points
|
||||||
branchSpeed: 2, // Pixels per frame
|
branchSpeed: 2, // Pixels per frame
|
||||||
|
|
@ -20,9 +25,8 @@
|
||||||
branchChance: 0.03, // Chance to spawn new branch per frame
|
branchChance: 0.03, // Chance to spawn new branch per frame
|
||||||
turnAngle: 0.3, // Max random turn per frame (radians)
|
turnAngle: 0.3, // Max random turn per frame (radians)
|
||||||
hueShiftSpeed: 0.2, // Color cycling speed
|
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
|
shatterDuration: 2500, // Milliseconds for shatter effect
|
||||||
dissolveDuration: 2000, // Milliseconds for dissolve effect
|
|
||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,6 +37,38 @@
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
ctx = canvas.getContext('2d');
|
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() {
|
function initCrystal() {
|
||||||
|
|
@ -42,6 +78,9 @@
|
||||||
phase = 'growing';
|
phase = 'growing';
|
||||||
phaseStartTime = performance.now();
|
phaseStartTime = performance.now();
|
||||||
|
|
||||||
|
// Reset collision grid
|
||||||
|
initGrid();
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.fillStyle = 'black';
|
ctx.fillStyle = 'black';
|
||||||
|
|
@ -74,8 +113,6 @@
|
||||||
updateGrowing();
|
updateGrowing();
|
||||||
} else if (phase === 'shattering') {
|
} else if (phase === 'shattering') {
|
||||||
updateShattering();
|
updateShattering();
|
||||||
} else if (phase === 'dissolving') {
|
|
||||||
updateDissolving();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +137,15 @@
|
||||||
continue;
|
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
|
// Store point for shatter effect
|
||||||
crystalPoints.push({
|
crystalPoints.push({
|
||||||
x: newX,
|
x: newX,
|
||||||
|
|
@ -133,26 +179,30 @@
|
||||||
generation: branch.generation + 1
|
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
|
// Add new branches
|
||||||
branches.push(...newBranches);
|
branches.push(...newBranches);
|
||||||
|
|
||||||
// Check if we should shatter (too many points or no more branches)
|
// Check if we should shatter (coverage threshold reached or no more branches)
|
||||||
if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) {
|
const coverage = getCoverage();
|
||||||
|
if (coverage > 0.75 || (branches.length === 0 && crystalPoints.length > 100)) {
|
||||||
startShatter();
|
startShatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn new seeds occasionally if branches are dying off
|
// Spawn new seeds if branches are dying off and we haven't filled the screen
|
||||||
if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) {
|
if (branches.length < 10 && coverage < 0.7) {
|
||||||
const x = Math.random() * width;
|
// Find an unoccupied spot for the new seed
|
||||||
const y = Math.random() * height;
|
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);
|
const branchCount = 2 + Math.floor(Math.random() * 3);
|
||||||
for (let j = 0; j < branchCount; j++) {
|
for (let j = 0; j < branchCount; j++) {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
|
@ -167,6 +217,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startShatter() {
|
function startShatter() {
|
||||||
phase = 'shattering';
|
phase = 'shattering';
|
||||||
|
|
@ -214,32 +265,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed >= CONFIG.shatterDuration) {
|
if (elapsed >= CONFIG.shatterDuration) {
|
||||||
phase = 'dissolving';
|
initCrystal(); // Go directly to regrow
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 isRefreshing = false;
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
let consecutiveFailures = 0;
|
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
|
// Silent token refresh function with retry logic
|
||||||
// Returns: { success: boolean, isAuthError: boolean }
|
// Returns: { success: boolean, isAuthError: boolean }
|
||||||
|
|
@ -90,6 +93,9 @@ export async function refreshAccessToken() {
|
||||||
function startTokenRefresh() {
|
function startTokenRefresh() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
|
// Setup visibility handler to refresh when tab becomes visible
|
||||||
|
setupVisibilityHandler();
|
||||||
|
|
||||||
// Clear any existing interval
|
// Clear any existing interval
|
||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(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() {
|
function createAuthStore() {
|
||||||
const { subscribe, set, update } = writable({
|
const { subscribe, set, update } = writable({
|
||||||
user: null,
|
user: null,
|
||||||
|
|
@ -136,11 +160,24 @@ function createAuthStore() {
|
||||||
|
|
||||||
// Use cookie-based auth - no localStorage tokens
|
// Use cookie-based auth - no localStorage tokens
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/me', {
|
let response = await fetch('/api/user/me', {
|
||||||
credentials: 'include', // Send cookies
|
credentials: 'include', // Send cookies
|
||||||
cache: 'no-store' // Always fetch fresh data
|
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) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ user: data.user, loading: false });
|
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) {
|
if (state.user) {
|
||||||
screensaver.init({
|
screensaver.init({
|
||||||
screensaverEnabled: state.user.screensaverEnabled,
|
screensaverEnabled: state.user.screensaverEnabled,
|
||||||
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes
|
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes,
|
||||||
|
screensaverType: state.user.screensaverType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -58,7 +59,8 @@
|
||||||
if ($auth.user) {
|
if ($auth.user) {
|
||||||
screensaver.init({
|
screensaver.init({
|
||||||
screensaverEnabled: $auth.user.screensaverEnabled,
|
screensaverEnabled: $auth.user.screensaverEnabled,
|
||||||
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes
|
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes,
|
||||||
|
screensaverType: $auth.user.screensaverType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -562,6 +564,13 @@
|
||||||
</svg>
|
</svg>
|
||||||
Games
|
Games
|
||||||
</a>
|
</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">
|
<a href="/stats" class="dropdown-item">
|
||||||
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M18 20V10M12 20V4M6 20v-6"/>
|
<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