653 lines
17 KiB
Vue
653 lines
17 KiB
Vue
<template>
|
||
<div class="village-page">
|
||
<h1>My Village</h1>
|
||
|
||
<div v-if="pending" class="loading">Loading your village...</div>
|
||
|
||
<div v-else-if="error" class="error-container">
|
||
<p v-if="error.statusCode === 401">Please log in to view your village.</p>
|
||
<p v-else>An error occurred while fetching your village data. Please try again.</p>
|
||
</div>
|
||
|
||
<div v-else-if="villageData" class="village-container">
|
||
<div class="village-grid-wrapper">
|
||
<div class="village-grid">
|
||
<div
|
||
v-for="tile in villageData.tiles"
|
||
:key="tile.id"
|
||
class="tile"
|
||
:class="[tileClasses(tile), { selected: selectedTile && selectedTile.id === tile.id }]"
|
||
@click="selectTile(tile)"
|
||
>
|
||
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tile Info Overlay -->
|
||
<div v-if="selectedTile" class="tile-overlay-backdrop" @click="selectedTile = null">
|
||
<div class="tile-overlay-panel" @click.stop>
|
||
|
||
<h2>{{ getTileTitle(selectedTile) }}</h2>
|
||
<p class="tile-description">{{ getTileDescription(selectedTile) }}</p>
|
||
|
||
<div v-if="selectedTile.availableActions && selectedTile.availableActions.length > 0" class="actions-container">
|
||
<h3 class="actions-header">Что здесь можно сделать?</h3>
|
||
<div class="actions-list">
|
||
<!-- Build Actions -->
|
||
<div v-if="selectedTile.availableActions.some(a => a.type === 'BUILD')" class="build-section">
|
||
<div class="build-card-grid">
|
||
<div
|
||
v-for="action in selectedTile.availableActions.filter(a => a.type === 'BUILD')"
|
||
:key="action.buildingType"
|
||
class="building-card"
|
||
:class="{ disabled: !action.isEnabled }"
|
||
>
|
||
<div class="building-icon">{{ getBuildingEmoji(action.buildingType) }}</div>
|
||
<h5>{{ getBuildingName(action.buildingType) }}</h5>
|
||
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
|
||
<div class="building-footer">
|
||
<div class="cost">
|
||
{{ action.cost }} монет
|
||
</div>
|
||
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)">
|
||
{{ getActionLabel(action) }}
|
||
</button>
|
||
</div>
|
||
<div v-if="!action.isEnabled" class="disabled-overlay">
|
||
<span>{{ getDisabledReasonText(action) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button @click="selectedTile = null" class="close-overlay-button">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin Panel -->
|
||
<div v-if="villageData?.user?.id === 1" class="admin-panel">
|
||
<h3>Admin Tools</h3>
|
||
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
|
||
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
|
||
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
|
||
</div>
|
||
<!-- Event Log -->
|
||
<div v-if="villageEvents?.length" class="event-log-container">
|
||
<h4>Activity Log</h4>
|
||
<table class="event-log-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Event</th>
|
||
<th>Coins</th>
|
||
<th>EXP</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="event in villageEvents" :key="event.id">
|
||
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
|
||
<td>{{ event.message }}</td>
|
||
<td>{{ event.coins }}</td>
|
||
<td>{{ event.exp }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue';
|
||
import { useAuth } from '~/composables/useAuth'; // Import useAuth
|
||
|
||
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser
|
||
|
||
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
|
||
lazy: true,
|
||
server: false, // Ensure this runs on the client-side
|
||
});
|
||
|
||
const { data: villageEvents, refresh: refreshEvents } = await useFetch('/api/village/events', {
|
||
lazy: true,
|
||
server: false,
|
||
});
|
||
|
||
const selectedTile = ref(null);
|
||
|
||
const getTileEmoji = (tile) => {
|
||
if (tile.terrainState === 'CLEARING') return '⏳';
|
||
if (tile.object) {
|
||
switch (tile.object.type) {
|
||
case 'HOUSE': return '🏠';
|
||
case 'FIELD': return '🌱';
|
||
case 'LUMBERJACK': return '🪓';
|
||
case 'QUARRY': return '⛏️';
|
||
case 'WELL': return '💧';
|
||
default: return '❓';
|
||
}
|
||
}
|
||
switch (tile.terrainType) {
|
||
case 'BLOCKED_TREE': return '🌳';
|
||
case 'BLOCKED_STONE': return '🪨';
|
||
case 'EMPTY': return '';
|
||
default: return '❓';
|
||
}
|
||
};
|
||
|
||
const tileClasses = (tile) => {
|
||
return {
|
||
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
|
||
'tile-object': !!tile.object,
|
||
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
|
||
};
|
||
};
|
||
|
||
const selectTile = (tile) => {
|
||
selectedTile.value = tile;
|
||
};
|
||
|
||
const getActionLabel = (action) => {
|
||
if (action.type === 'BUILD') {
|
||
return `Построить`;
|
||
}
|
||
return action.type;
|
||
};
|
||
|
||
const getBuildingName = (buildingType) => {
|
||
const buildingNameMap = {
|
||
'HOUSE': 'Дом',
|
||
'FIELD': 'Поле',
|
||
'LUMBERJACK': 'Домик лесоруба',
|
||
'QUARRY': 'Каменоломня',
|
||
'WELL': 'Колодец',
|
||
};
|
||
return buildingNameMap[buildingType] || buildingType;
|
||
};
|
||
|
||
const getBuildingEmoji = (buildingType) => {
|
||
const emojiMap = {
|
||
'HOUSE': '🏠',
|
||
'FIELD': '🌱',
|
||
'LUMBERJACK': '🪓',
|
||
'QUARRY': '⛏️',
|
||
'WELL': '💧',
|
||
};
|
||
return emojiMap[buildingType] || '❓';
|
||
};
|
||
|
||
|
||
const getTileTitle = (tile) => {
|
||
if (tile.object) {
|
||
const buildingMap = {
|
||
'HOUSE': 'Дом',
|
||
'FIELD': 'Поле',
|
||
'LUMBERJACK': 'Домик лесоруба',
|
||
'QUARRY': 'Каменоломня',
|
||
'WELL': 'Колодец',
|
||
};
|
||
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} (${tile.x}, ${tile.y})`;
|
||
}
|
||
const terrainMap = {
|
||
'BLOCKED_TREE': 'Лесной участок',
|
||
'BLOCKED_STONE': 'Каменистый участок',
|
||
'EMPTY': 'Пустырь',
|
||
};
|
||
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} (${tile.x}, ${tile.y})`;
|
||
};
|
||
|
||
const getTileDescription = (tile) => {
|
||
if (tile.terrainState === 'CLEARING') return 'Идет расчистка...';
|
||
if (tile.object) return `Здесь стоит ${tile.object.type}.`;
|
||
|
||
const descriptionMap = {
|
||
'BLOCKED_TREE': 'Густые деревья, которые можно расчистить с помощью лесоруба.',
|
||
'BLOCKED_STONE': 'Каменные завалы, которые можно убрать с помощью каменотеса.',
|
||
'EMPTY': 'Свободное место, готовое к застройке.',
|
||
};
|
||
return descriptionMap[tile.terrainType] || 'Это место выглядит странно.';
|
||
};
|
||
|
||
const getBuildingDescription = (buildingType) => {
|
||
const descriptions = {
|
||
'HOUSE': 'Увеличивает лимит рабочих на 1. Рабочие нужны для производственных зданий.',
|
||
'FIELD': 'Ежедневно приносит опыт. Производство можно увеличить, построив рядом колодец.',
|
||
'LUMBERJACK': 'Позволяет вашим рабочим расчищать участки с деревьями.',
|
||
'QUARRY': 'Позволяет вашим рабочим разбирать каменные завалы.',
|
||
'WELL': 'Увеличивает производство опыта на соседних полях.',
|
||
};
|
||
return descriptions[buildingType] || '';
|
||
};
|
||
|
||
const getDisabledReasonText = (action) => {
|
||
const reasons = {
|
||
'Not enough coins': 'Не хватает монет.',
|
||
'Not enough workers': 'Не хватает свободных рабочих (постройте больше домов).',
|
||
'Requires Lumberjack': 'Нужен домик лесоруба, чтобы убирать деревья.',
|
||
'Requires Quarry': 'Нужна каменоломня, чтобы убирать камни.',
|
||
};
|
||
return reasons[action.disabledReason] || action.disabledReason;
|
||
};
|
||
|
||
const isSubmitting = ref(false);
|
||
|
||
const handleActionClick = async (action) => {
|
||
if (isSubmitting.value) return;
|
||
|
||
isSubmitting.value = true;
|
||
try {
|
||
const response = await useFetch('/api/village/action', {
|
||
method: 'POST',
|
||
body: {
|
||
tileId: selectedTile.value.id,
|
||
actionType: action.type,
|
||
payload: {
|
||
...(action.type === 'BUILD' && { buildingType: action.buildingType }),
|
||
},
|
||
},
|
||
});
|
||
|
||
if (response.error.value) {
|
||
alert(response.error.value.data?.statusMessage || 'An unknown error occurred.');
|
||
} else {
|
||
villageData.value = response.data.value;
|
||
updateUser(response.data.value.user); // Update global user state
|
||
selectedTile.value = null;
|
||
await refreshEvents(); // Refresh the event log
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to perform action:', e);
|
||
alert('An unexpected error occurred. Please check the console.');
|
||
} finally {
|
||
isSubmitting.value = false;
|
||
}
|
||
};
|
||
|
||
const isSubmittingAdminAction = ref(false);
|
||
|
||
async function handleAdminAction(url) {
|
||
if (isSubmittingAdminAction.value) return;
|
||
isSubmittingAdminAction.value = true;
|
||
|
||
try {
|
||
const { error } = await useFetch(url, { method: 'POST' });
|
||
if (error.value) {
|
||
alert(error.value.data?.statusMessage || 'An admin action failed.');
|
||
} else {
|
||
await Promise.all([refreshVillageData(), refreshEvents()]);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to perform admin action:', e);
|
||
alert('An unexpected error occurred.');
|
||
} finally {
|
||
isSubmittingAdminAction.value = false;
|
||
}
|
||
}
|
||
|
||
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
|
||
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
|
||
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
|
||
</script>
|
||
|
||
<style scoped>
|
||
.village-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 20px;
|
||
font-family: sans-serif;
|
||
min-height: calc(100vh - 120px);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.loading, .error-container {
|
||
margin-top: 50px;
|
||
font-size: 1.2em;
|
||
color: #555;
|
||
}
|
||
|
||
.village-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
width: 100%;
|
||
max-width: 350px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.village-grid-wrapper {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.village-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 60px);
|
||
grid-template-rows: repeat(7, 60px);
|
||
gap: 4px;
|
||
border: 2px solid #333;
|
||
padding: 4px;
|
||
background-color: #f0f0f0;
|
||
width: fit-content;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.tile {
|
||
width: 60px;
|
||
height: 60px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border: 1px solid #ccc;
|
||
background-color: #fff; /* Default white for empty */
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.tile.tile-blocked {
|
||
background-color: #e0e0e0; /* Light gray for blocked (tree/stone) */
|
||
}
|
||
|
||
.tile.tile-object {
|
||
background-color: #e6ffe6; /* Light green for tiles with user objects */
|
||
}
|
||
|
||
.tile:hover {
|
||
background-color: #ffffe0; /* Light yellow for hover */
|
||
border: 1px solid #ffcc00; /* Subtle yellow border */
|
||
}
|
||
|
||
.tile.selected {
|
||
border: 2px solid #007bff;
|
||
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||
}
|
||
|
||
.tile-content {
|
||
font-size: 2em;
|
||
}
|
||
|
||
.tile-overlay-backdrop {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.tile-overlay-panel {
|
||
background-color: #fff;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
padding: 20px;
|
||
border-top-left-radius: 15px;
|
||
border-top-right-radius: 15px;
|
||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||
transform: translateY(0);
|
||
transition: transform 0.3s ease-out;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.tile-overlay-backdrop {
|
||
align-items: center;
|
||
}
|
||
.tile-overlay-panel {
|
||
border-radius: 15px;
|
||
max-height: 80vh;
|
||
}
|
||
}
|
||
|
||
.tile-overlay-panel h2 {
|
||
margin-top: 0;
|
||
text-align: center;
|
||
color: #333;
|
||
}
|
||
|
||
.tile-description {
|
||
text-align: center;
|
||
margin-top: -10px;
|
||
color: #666;
|
||
font-style: italic;
|
||
}
|
||
|
||
.actions-container {
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.actions-header {
|
||
text-align: center;
|
||
font-size: 1.2em;
|
||
color: #444;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.actions-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.action-item {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.action-item button {
|
||
width: 100%;
|
||
padding: 10px 15px;
|
||
border: 1px solid #007bff;
|
||
background-color: #007bff;
|
||
color: white;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 1em;
|
||
transition: background-color 0.2s, opacity 0.2s;
|
||
}
|
||
|
||
.action-item button:hover:not(:disabled) {
|
||
background-color: #0056b3;
|
||
}
|
||
|
||
.action-item button:disabled {
|
||
background-color: #e9ecef;
|
||
color: #6c757d;
|
||
cursor: not-allowed;
|
||
border-color: #e9ecef;
|
||
}
|
||
|
||
.build-section {
|
||
margin-top: 20px;
|
||
padding-top: 15px;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
.build-section h4 {
|
||
text-align: center;
|
||
color: #555;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.building-description {
|
||
font-size: 0.85em;
|
||
color: #555;
|
||
margin-top: 5px;
|
||
text-align: center;
|
||
}
|
||
|
||
.disabled-reason {
|
||
font-size: 0.8em;
|
||
color: #dc3545;
|
||
margin-top: 5px;
|
||
text-align: center;
|
||
}
|
||
|
||
.close-overlay-button {
|
||
width: 100%;
|
||
padding: 10px 15px;
|
||
margin-top: 15px;
|
||
background-color: #6c757d;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.close-overlay-button:hover {
|
||
background-color: #5a6268;
|
||
}
|
||
|
||
.admin-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
padding: 15px;
|
||
border: 2px dashed #dc3545;
|
||
border-radius: 10px;
|
||
max-width: 350px;
|
||
width: 100%;
|
||
}
|
||
|
||
.admin-panel h3 {
|
||
margin: 0 0 10px 0;
|
||
color: #dc3545;
|
||
}
|
||
|
||
.admin-panel button {
|
||
width: 100%;
|
||
padding: 8px;
|
||
background-color: #dc3545;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.admin-panel button:disabled {
|
||
background-color: #e9ecef;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.event-log-container {
|
||
margin-top: 20px;
|
||
width: 100%;
|
||
max-width: 350px;
|
||
}
|
||
|
||
.event-log-container h4 {
|
||
text-align: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.event-log-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.8em;
|
||
}
|
||
|
||
.event-log-table th, .event-log-table td {
|
||
border: 1px solid #ccc;
|
||
padding: 6px;
|
||
text-align: left;
|
||
}
|
||
|
||
.event-log-table th {
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
/* New Build Card Styles */
|
||
.build-card-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.building-card {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: 130px;
|
||
padding: 10px;
|
||
border: 1px solid #ccc;
|
||
border-radius: 8px;
|
||
background-color: #f9f9f9;
|
||
text-align: center;
|
||
transition: box-shadow 0.2s;
|
||
}
|
||
|
||
.building-card:not(.disabled):hover {
|
||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.building-card.disabled {
|
||
background-color: #e9ecef;
|
||
}
|
||
|
||
.building-icon {
|
||
font-size: 2em;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.building-card h5 {
|
||
margin: 0 0 5px 0;
|
||
font-size: 1em;
|
||
color: #333;
|
||
}
|
||
|
||
.building-card .building-description {
|
||
font-size: 0.75em;
|
||
color: #666;
|
||
flex-grow: 1; /* Pushes footer down */
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.building-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
margin-top: auto; /* Pushes footer to bottom */
|
||
}
|
||
|
||
.building-footer .cost {
|
||
font-weight: bold;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.building-footer button {
|
||
padding: 5px 10px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.disabled-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(233, 236, 239, 0.8);
|
||
color: #dc3545;
|
||
font-weight: bold;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
text-align: center;
|
||
padding: 5px;
|
||
border-radius: 8px;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.disabled-overlay span {
|
||
font-size: 0.9em;
|
||
}
|
||
</style> |