diff --git a/app.js b/app.js index cd35633..c51fafb 100644 --- a/app.js +++ b/app.js @@ -29,6 +29,7 @@ const { registerChatHandlers } = require("./sockets/chat"); const boosterRoutes = require("./routes/booster.route"); const pointsRoutes = require("./routes/points.route"); const combineRoutes = require("./routes/combine.route"); +const bazaarRoutes = require("./routes/bazaar.route"); const compression = require("compression"); @@ -387,6 +388,7 @@ app.use("/api", boosterRoutes); app.use("/api", require("./routes/daily.route")); app.use("/api/points", pointsRoutes); app.use("/api", combineRoutes); +app.use("/api", bazaarRoutes); /* ======================== 404 Handler diff --git a/public/css/bazaar.css b/public/css/bazaar.css index f0cfd9d..bd1ffe1 100644 --- a/public/css/bazaar.css +++ b/public/css/bazaar.css @@ -119,3 +119,197 @@ letter-spacing: 2px; opacity: 0.7; } + +/* ══════════════════════════════════════════════ + HÄNDLER – Karten-Grid +══════════════════════════════════════════════ */ + +.baz-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + overflow-y: auto; + padding: 12px 14px; + align-content: start; +} + +.baz-card { + position: relative; + border: 2px solid #6b4b2a; + border-radius: 8px; + overflow: visible; + background: #1a0f04; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; + aspect-ratio: 3/4; + display: flex; + flex-direction: column; +} +.baz-card:hover { + transform: scale(1.06) translateY(-4px); + border-color: #f0d060; + box-shadow: 0 8px 24px rgba(0,0,0,0.7), 0 0 14px rgba(200,160,60,0.3); + z-index: 10; +} +.baz-card img { + width: 100%; height: 100%; + object-fit: cover; display: block; + border-radius: 6px 6px 0 0; +} + +/* Stats */ +.baz-stat-atk { + position: absolute; right: 8px; top: 38%; + transform: translateY(-50%); + background: rgba(180,40,20,0.88); border: 1px solid #ff6040; + border-radius: 45px; color: #fff; + font-family: "Cinzel", serif; font-size: 9px; font-weight: bold; + padding: 2px 4px; z-index: 5; +} +.baz-stat-def { + position: absolute; left: 8px; top: 38%; + transform: translateY(-50%); + background: rgba(20,80,180,0.88); border: 1px solid #4090ff; + border-radius: 45px; color: #fff; + font-family: "Cinzel", serif; font-size: 9px; font-weight: bold; + padding: 2px 4px; z-index: 5; +} +.baz-stat-cd { + position: absolute; bottom: 32px; right: 5px; + width: 18px; height: 18px; border-radius: 50%; + background: rgba(0,0,0,0.75); border: 1px solid #f0d060; + display: flex; align-items: center; justify-content: center; + font-family: "Cinzel", serif; font-size: 8px; font-weight: bold; + color: #f0d9a6; z-index: 5; pointer-events: none; +} + +/* Rarity */ +.baz-rarity { + position: absolute; top: 62%; left: 0; right: 0; + display: flex; justify-content: center; flex-wrap: wrap; + gap: 1px; pointer-events: none; z-index: 4; +} + +/* Range / Race */ +.baz-range-race { + position: absolute; top: 74%; left: 0; right: 0; + display: flex; justify-content: center; gap: 3px; + pointer-events: none; z-index: 6; +} +.baz-badge-range, .baz-badge-race { + display: flex; align-items: center; gap: 2px; + padding: 1px 3px; border-radius: 20px; + font-family: "Cinzel", serif; font-size: 8px; font-weight: bold; line-height: 1; +} +.baz-badge-range { background: rgba(30,20,0,0.82); border: 1px solid #e8b84b; color: #e8b84b; } +.baz-badge-race { background: rgba(0,25,0,0.82); border: 1px solid #7de87d; color: #7de87d; } + +/* Preis-Badge */ +.baz-price { + position: absolute; bottom: 0; left: 0; right: 0; + display: flex; justify-content: center; gap: 4px; flex-wrap: wrap; + padding: 2px 3px 3px; + background: linear-gradient(transparent, rgba(0,0,0,0.85)); + border-radius: 0 0 6px 6px; + z-index: 7; pointer-events: none; +} +.baz-price-gold, .baz-price-silver { + font-family: "Cinzel", serif; font-size: 8px; font-weight: bold; + line-height: 1.3; +} +.baz-price-wood { color: #c8a050; } +.baz-price-iron { color: #a0b0c0; } +.baz-price-gold { color: #f0d060; } +.baz-price-gems { color: #a060ff; } +.baz-price-silver { color: #c0c0c0; } + +/* Pagination */ +.baz-pagination { + display: flex; align-items: center; justify-content: center; + gap: 5px; padding: 8px 10px; flex-shrink: 0; + border-top: 1px solid #3a2810; +} +.baz-page-btn { + background: linear-gradient(#3a2810, #1a0f04); + border: 1px solid #8b6a3c; border-radius: 5px; + color: #f0d9a6; font-family: "Cinzel", serif; + font-size: 11px; padding: 3px 10px; cursor: pointer; transition: 0.15s; +} +.baz-page-btn:hover { border-color: #f0d060; } +.baz-page-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.baz-page-btn.baz-page-active { + background: linear-gradient(#6b4b2a, #3c2414); + border-color: #f0d060; color: #fff5cc; +} +.baz-page-info { color: #a08060; font-size: 11px; } + +/* Leer / Laden */ +.baz-empty, .baz-loading { + grid-column: 1 / -1; text-align: center; + color: #8b6a3c; font-family: "Cinzel", serif; + font-size: 13px; padding: 40px 0; +} + +/* Währung im Header */ +.baz-currency { + font-family: "Cinzel", serif; font-size: 12px; + color: #f0d9a6; letter-spacing: 1px; +} + +/* Kauf-Bestätigung */ +.baz-confirm-backdrop { + position: absolute; inset: 0; + background: rgba(0,0,0,0.7); + border-radius: 0 0 10px 10px; + z-index: 50; +} +.baz-confirm-box { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + z-index: 51; + background: linear-gradient(#2a1a08, #1a0f04); + border: 2px solid #c8960c; border-radius: 12px; + padding: 24px 28px; min-width: 260px; + display: flex; flex-direction: column; + align-items: center; gap: 12px; + font-family: "Cinzel", serif; +} +.baz-confirm-img-wrap { + width: 120px; aspect-ratio: 3/4; + border: 2px solid #6b4b2a; border-radius: 8px; overflow: hidden; +} +.baz-confirm-img-wrap img { width: 100%; height: 100%; object-fit: cover; } +.baz-confirm-name { font-size: 15px; font-weight: bold; color: #f0d9a6; } +.baz-confirm-price { font-size: 13px; color: #f0d060; } +.baz-confirm-warn { font-size: 11px; color: #ff6666; } +.baz-confirm-btns { display: flex; gap: 12px; } +.baz-btn-cancel, .baz-btn-buy { + padding: 8px 20px; border-radius: 7px; cursor: pointer; + font-family: "Cinzel", serif; font-size: 12px; font-weight: bold; + transition: 0.2s; +} +.baz-btn-cancel { + background: linear-gradient(#3a2810, #1a0f04); + border: 2px solid #6b4b2a; color: #a08060; +} +.baz-btn-cancel:hover { border-color: #f0d060; color: #f0d9a6; } +.baz-btn-buy { + background: linear-gradient(#6b4b2a, #3c2414); + border: 2px solid #f0d060; color: #fff5cc; +} +.baz-btn-buy:hover:not(:disabled) { box-shadow: 0 0 12px rgba(200,160,60,0.5); } +.baz-btn-buy:disabled { opacity: 0.35; cursor: not-allowed; } + +/* Toast */ +.baz-toast { + position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); + background: linear-gradient(#4a2808, #2a1004); border: 2px solid #8b6a3c; + border-radius: 8px; color: #f0d9a6; font-family: "Cinzel", serif; + font-size: 13px; padding: 10px 22px; z-index: 9999; pointer-events: none; +} + +/* Header-Währungen */ +.baz-header-right { + display: flex; align-items: center; gap: 14px; +} diff --git a/public/js/buildings/bazaar.js b/public/js/buildings/bazaar.js index 0a93d6c..0350b61 100644 --- a/public/js/buildings/bazaar.js +++ b/public/js/buildings/bazaar.js @@ -1,107 +1,252 @@ /* ============================================================ public/js/buildings/bazaar.js - Bazaar – eigenes Parchment-Popup (wie Gaststätte) ============================================================ */ +const BAZAAR_PER_PAGE = 18; let baz_initialized = false; +let baz_page = 1; +let baz_wood = 0, baz_iron = 0, baz_gold = 0, baz_gems = 0; + +const BAZ_SVG_RANGE = ``; +const BAZ_SVG_RACE = ``; + +const RARITY_CRYSTALS = { + 1:"roter-cristal.png",2:"blauer-cristal.png",3:"gelber-cristal.png", + 4:"gruener-cristal.png",5:"oranger-cristal.png",6:"violet-cristal.png",7:"pinker-cristal.png" +}; +function rarityImgs(rarity, size=11) { + const file = RARITY_CRYSTALS[String(rarity)]; + if (!file) return ""; + const img = `${rarity}`; + return img.repeat(parseInt(rarity)||0); +} -/* ── CSS einmalig laden ── */ function loadCSS() { if (!document.querySelector('link[href="/css/bazaar.css"]')) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = "/css/bazaar.css"; - document.head.appendChild(link); + const l = document.createElement("link"); + l.rel = "stylesheet"; l.href = "/css/bazaar.css"; + document.head.appendChild(l); } } -/* ── Popup-Element erzeugen falls noch nicht vorhanden ── */ function ensurePopup() { if (document.getElementById("bazaar-popup")) return; - const popup = document.createElement("div"); popup.id = "bazaar-popup"; popup.className = "qm-popup"; popup.innerHTML = `
Bazaar - +
+ 🪵 + ⚙️ + 🪙 + 💎 + +
-
-
+
Händler
-
- Demnächst verfügbar… -
+
Lade Karten…
+
-
+
Auktionen
-
- Demnächst verfügbar… -
+
Demnächst verfügbar…
-
+
Tauschbörse
-
- Demnächst verfügbar… -
+
Demnächst verfügbar…
-
- `; +
`; document.body.appendChild(popup); - /* Schließen-Button */ popup.querySelector("#bazaar-close").addEventListener("click", closeBazaar); - /* Tab-Klick */ popup.querySelectorAll(".mp-tab").forEach((btn) => { btn.addEventListener("click", () => { - popup.querySelectorAll(".mp-tab").forEach((t) => t.classList.remove("mp-tab-active")); - popup.querySelectorAll(".mp-panel").forEach((p) => p.classList.remove("active")); + popup.querySelectorAll(".mp-tab").forEach(t => t.classList.remove("mp-tab-active")); + popup.querySelectorAll(".mp-panel").forEach(p => p.classList.remove("active")); btn.classList.add("mp-tab-active"); document.getElementById(btn.dataset.tab)?.classList.add("active"); }); }); - /* Drag-fähig machen */ + /* Drag */ const header = popup.querySelector(".qm-popup-header"); - let isDragging = false, startX, startY, startLeft, startTop; - header.style.cursor = "grab"; - header.addEventListener("mousedown", (e) => { - if (e.target.classList.contains("qm-popup-close")) return; - isDragging = true; - header.style.cursor = "grabbing"; - const rect = popup.getBoundingClientRect(); - startX = e.clientX; startY = e.clientY; - startLeft = rect.left; startTop = rect.top; - popup.style.transform = "none"; - popup.style.left = startLeft + "px"; - popup.style.top = startTop + "px"; + let dragging=false, sx,sy,sl,st; + header.style.cursor="grab"; + header.addEventListener("mousedown",(e)=>{ + if(e.target.classList.contains("qm-popup-close"))return; + dragging=true; header.style.cursor="grabbing"; + const r=popup.getBoundingClientRect(); + sx=e.clientX;sy=e.clientY;sl=r.left;st=r.top; + popup.style.transform="none"; + popup.style.left=sl+"px";popup.style.top=st+"px"; e.preventDefault(); }); - document.addEventListener("mousemove", (e) => { - if (!isDragging) return; - popup.style.left = (startLeft + (e.clientX - startX)) + "px"; - popup.style.top = (startTop + (e.clientY - startY)) + "px"; - }); - document.addEventListener("mouseup", () => { - if (!isDragging) return; - isDragging = false; - header.style.cursor = "grab"; + document.addEventListener("mousemove",(e)=>{ + if(!dragging)return; + popup.style.left=(sl+(e.clientX-sx))+"px"; + popup.style.top=(st+(e.clientY-sy))+"px"; }); + document.addEventListener("mouseup",()=>{dragging=false;header.style.cursor="grab";}); +} + +function updateCurrencyDisplay() { + const fmt = n => n.toLocaleString("de-DE"); + const w=document.getElementById("baz-wood"); if(w) w.textContent=fmt(baz_wood); + const i=document.getElementById("baz-iron"); if(i) i.textContent=fmt(baz_iron); + const g=document.getElementById("baz-gold"); if(g) g.textContent=fmt(baz_gold); + const d=document.getElementById("baz-gems"); if(d) d.textContent=fmt(baz_gems); +} + +async function loadShopCards() { + const grid = document.getElementById("baz-grid"); + const pagination = document.getElementById("baz-pagination"); + if (!grid) return; + grid.innerHTML = `
Lade Karten…
`; + if (pagination) pagination.innerHTML = ""; + + try { + const res = await fetch(`/api/bazaar/cards?page=${baz_page}&limit=${BAZAAR_PER_PAGE}`); + if (!res.ok) throw new Error(res.status); + const data = await res.json(); + + baz_wood=data.wood; baz_iron=data.iron; baz_gold=data.gold; baz_gems=data.gems; + updateCurrencyDisplay(); + + if (!data.cards.length) { + grid.innerHTML = `
Keine Karten verfügbar.
`; + return; + } + + grid.innerHTML = data.cards.map((c) => { + const prices = [ + c.price_wood > 0 ? `🪵 ${c.price_wood}` : "", + c.price_iron > 0 ? `⚙️ ${c.price_iron}` : "", + c.price_gold > 0 ? `🪙 ${c.price_gold}` : "", + c.price_gems > 0 ? `💎 ${c.price_gems}` : "", + ].filter(Boolean).join(""); + + return ` +
+ ${c.name} + ${c.attack != null ? `${c.attack}` : ""} + ${c.defends != null ? `${c.defends}` : ""} + ${c.cooldown != null ? `${c.cooldown}` : ""} + ${c.rarity ? `
${rarityImgs(c.rarity,11)}
` : ""} + ${(c.range != null || c.race != null) ? ` +
+ ${c.range != null ? `${BAZ_SVG_RANGE} ${c.range}` : ""} + ${c.race != null ? `${BAZ_SVG_RACE} ${c.race}` : ""} +
` : ""} +
${prices || "Kostenlos"}
+
`; + }).join(""); + + grid.querySelectorAll(".baz-card").forEach((el) => { + el.addEventListener("click", () => { + const card = data.cards.find(c => c.id === parseInt(el.dataset.cardId)); + if (card) showBuyConfirm(card); + }); + }); + + renderPagination(pagination, data.totalPages, data.total); + } catch (err) { + grid.innerHTML = `
Fehler beim Laden.
`; + console.error("[bazaar]", err); + } +} + +function showBuyConfirm(card) { + document.getElementById("baz-confirm-modal")?.remove(); + + const canAfford = + baz_wood >= card.price_wood && + baz_iron >= card.price_iron && + baz_gold >= card.price_gold && + baz_gems >= card.price_gems; + + const priceStr = [ + card.price_wood > 0 ? `🪵 ${card.price_wood} Holz` : "", + card.price_iron > 0 ? `⚙️ ${card.price_iron} Eisen` : "", + card.price_gold > 0 ? `🪙 ${card.price_gold} Gold` : "", + card.price_gems > 0 ? `💎 ${card.price_gems} Gems` : "", + ].filter(Boolean).join(" "); + + const modal = document.createElement("div"); + modal.id = "baz-confirm-modal"; + modal.innerHTML = ` +
+
+
+ ${card.name} +
+
${card.name}
+
${priceStr || "Kostenlos"}
+ ${!canAfford ? `
⚠ Nicht genug Ressourcen
` : ""} +
+ + +
+
`; + document.getElementById("bazaar-popup").appendChild(modal); + + modal.querySelector("#baz-cancel").onclick = () => modal.remove(); + modal.querySelector(".baz-confirm-backdrop").onclick = () => modal.remove(); + modal.querySelector("#baz-confirm").onclick = async () => { + const btn = modal.querySelector("#baz-confirm"); + btn.disabled = true; btn.textContent = "…"; + try { + const res = await fetch("/api/bazaar/buy", { + method:"POST", headers:{"Content-Type":"application/json"}, + body: JSON.stringify({ card_id: card.id }), + }); + const data = await res.json(); + if (!res.ok) { btn.textContent = data.error||"Fehler"; setTimeout(()=>modal.remove(),2000); return; } + baz_wood=data.wood; baz_iron=data.iron; baz_gold=data.gold; baz_gems=data.gems; + updateCurrencyDisplay(); + modal.remove(); + showToast(`✅ ${card.name} gekauft!`); + await loadShopCards(); + } catch { btn.textContent="Fehler"; setTimeout(()=>modal.remove(),2000); } + }; +} + +function renderPagination(el, totalPages, total) { + if (!el||!totalPages||totalPages<=1) return; + el.innerHTML = ` + + ${Array.from({length:totalPages},(_,i)=>i+1).map(p=> + `` + ).join("")} + + ${total} Karten`; + el.querySelector("#baz-prev")?.addEventListener("click",async()=>{if(baz_page>1){baz_page--;await loadShopCards();}}); + el.querySelector("#baz-next")?.addEventListener("click",async()=>{if(baz_pagebtn.addEventListener("click",async()=>{baz_page=parseInt(btn.dataset.page);await loadShopCards();})); +} + +function showToast(msg) { + const t=document.createElement("div"); t.className="baz-toast"; t.textContent=msg; + document.body.appendChild(t); setTimeout(()=>t.remove(),2800); } function closeBazaar() { @@ -109,17 +254,14 @@ function closeBazaar() { document.getElementById("qm-overlay")?.classList.remove("active"); } -/* ── Öffentliche Funktion ── */ export function loadBazaar() { loadCSS(); ensurePopup(); - - const popup = document.getElementById("bazaar-popup"); - const overlay = document.getElementById("qm-overlay"); - - popup.style.left = "50%"; - popup.style.top = "50%"; - popup.style.transform = "translate(-50%, -50%) scale(1)"; + const popup=document.getElementById("bazaar-popup"); + const overlay=document.getElementById("qm-overlay"); + popup.style.left="50%"; popup.style.top="50%"; + popup.style.transform="translate(-50%, -50%) scale(1)"; popup.classList.add("active"); overlay?.classList.add("active"); + if (!baz_initialized) { baz_initialized=true; baz_page=1; loadShopCards(); } } diff --git a/routes/bazaar.route.js b/routes/bazaar.route.js new file mode 100644 index 0000000..980b109 --- /dev/null +++ b/routes/bazaar.route.js @@ -0,0 +1,153 @@ +/* ============================================================ + routes/bazaar.route.js +============================================================ */ + +const express = require("express"); +const router = express.Router(); +const db = require("../database/database"); + +function requireLogin(req, res, next) { + if (!req.session?.user) return res.status(401).json({ error: "Nicht eingeloggt" }); + next(); +} + +function getMaxRarity(playerLevel) { + if (playerLevel < 10) return 2; + if (playerLevel < 20) return 3; + if (playerLevel < 30) return 4; + if (playerLevel < 40) return 5; + return 6; +} + +/* ════════════════════════════════════════════ + GET /api/bazaar/cards?page=1&limit=18 +════════════════════════════════════════════ */ +router.get("/bazaar/cards", requireLogin, async (req, res) => { + const userId = req.session.user.id; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 18; + const offset = (page - 1) * limit; + + try { + const [[account]] = await db.query("SELECT level FROM accounts WHERE id = ?", [userId]); + const maxRarity = getMaxRarity(account?.level ?? 1); + + const [cards] = await db.query( + `SELECT + c.id, c.name, c.image, c.rarity, + c.attack, c.defends, c.cooldown, c.\`range\`, c.\`race\`, + COALESCE(p.price_wood, 0) AS price_wood, + COALESCE(p.price_iron, 0) AS price_iron, + COALESCE(p.price_gold, 0) AS price_gold, + COALESCE(p.price_gems, 0) AS price_gems + FROM cards c + LEFT JOIN card_shop_prices p ON p.rarity = c.rarity + WHERE c.rarity <= ? + ORDER BY c.rarity ASC, c.name ASC + LIMIT ? OFFSET ?`, + [maxRarity, limit, offset] + ); + + const [[{ total }]] = await db.query( + "SELECT COUNT(*) AS total FROM cards WHERE rarity <= ?", [maxRarity] + ); + + const [[currency]] = await db.query( + "SELECT wood, iron, gold, gems FROM account_currency WHERE account_id = ?", [userId] + ); + + res.json({ + cards, total, page, + totalPages: Math.ceil(total / limit), + wood: currency?.wood || 0, + iron: currency?.iron || 0, + gold: currency?.gold || 0, + gems: currency?.gems || 0, + }); + } catch (err) { + console.error("[bazaar/cards]", err); + res.status(500).json({ error: "DB Fehler" }); + } +}); + +/* ════════════════════════════════════════════ + POST /api/bazaar/buy { card_id } +════════════════════════════════════════════ */ +router.post("/bazaar/buy", requireLogin, async (req, res) => { + const userId = req.session.user.id; + const { card_id } = req.body; + if (!card_id) return res.status(400).json({ error: "card_id fehlt." }); + + try { + const [[card]] = await db.query( + `SELECT c.id, c.name, c.rarity, + COALESCE(p.price_wood, 0) AS price_wood, + COALESCE(p.price_iron, 0) AS price_iron, + COALESCE(p.price_gold, 0) AS price_gold, + COALESCE(p.price_gems, 0) AS price_gems + FROM cards c + LEFT JOIN card_shop_prices p ON p.rarity = c.rarity + WHERE c.id = ?`, [card_id] + ); + if (!card) return res.status(404).json({ error: "Karte nicht gefunden." }); + + const [[account]] = await db.query("SELECT level FROM accounts WHERE id = ?", [userId]); + if (parseInt(card.rarity) > getMaxRarity(account?.level ?? 1)) { + return res.status(403).json({ error: "Dein Level reicht für diese Karte nicht aus." }); + } + + const [[currency]] = await db.query( + "SELECT wood, iron, gold, gems FROM account_currency WHERE account_id = ?", [userId] + ); + if (!currency) return res.status(400).json({ error: "Keine Währungsdaten gefunden." }); + + const checks = [ + { key: "wood", label: "Holz" }, + { key: "iron", label: "Eisen" }, + { key: "gold", label: "Gold" }, + { key: "gems", label: "Gems" }, + ]; + for (const { key, label } of checks) { + const need = card[`price_${key}`]; + if (need > 0 && (currency[key] || 0) < need) { + return res.status(400).json({ + error: `Nicht genug ${label}. Benötigt: ${need}, Vorhanden: ${currency[key] || 0}` + }); + } + } + + await db.query( + `UPDATE account_currency + SET wood = wood - ?, + iron = iron - ?, + gold = gold - ?, + gems = gems - ? + WHERE account_id = ?`, + [card.price_wood, card.price_iron, card.price_gold, card.price_gems, userId] + ); + + await db.query( + `INSERT INTO user_cards (user_id, card_id, amount) VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE amount = amount + 1`, + [userId, card_id] + ); + + const [[updated]] = await db.query( + "SELECT wood, iron, gold, gems FROM account_currency WHERE account_id = ?", [userId] + ); + + res.json({ + success: true, + card: { id: card.id, name: card.name }, + wood: updated?.wood || 0, + iron: updated?.iron || 0, + gold: updated?.gold || 0, + gems: updated?.gems || 0, + }); + } catch (err) { + console.error("[bazaar/buy]", err); + res.status(500).json({ error: "DB Fehler" }); + } +}); + +module.exports = router;