habits.andr33v.ru/app/pages/village.vue

694 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container village-page-layout">
<h1>Моя деревня</h1>
<div v-if="pending" class="loading">Загрузка вашей деревни...</div>
<div v-else-if="error" class="error-container">
<p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
<div v-else>
<p>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
<pre>{{ error }}</pre>
</div>
</div>
<div v-else-if="villageData">
<div class="village-container">
<div class="village-grid-wrapper"> <!-- This will be the main grid container with labels -->
<div class="empty-corner"></div> <!-- Top-left empty cell -->
<div class="col-labels">
<div class="col-label" v-for="colLabel in ['A', 'B', 'C', 'D', 'E']" :key="colLabel">{{ colLabel }}</div>
</div>
<div class="row-labels">
<div class="row-label" v-for="rowLabel in ['7', '6', '5', '4', '3', '2', '1']" :key="rowLabel">{{ rowLabel }}</div>
</div>
<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 }]"
:style="{ 'grid-column': tile.x + 1, 'grid-row': tile.y + 1 }"
@click="selectTile(tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div>
</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)" class="btn btn-primary btn-sm">
{{ 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="btn btn-secondary close-overlay-button">Закрыть</button>
</div>
</div>
<div class="bottom-content">
<!-- 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="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Дата</th>
<th>Событие</th>
<th>Монеты</th>
<th>EXP</th>
</tr>
</thead>
<tbody>
<tr v-for="event in villageEvents" :key="event.id">
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
<td class="event-message">{{ formatMessageCoordinates(event.message) }}</td>
<td>{{ event.coins }}</td>
<td>{{ event.exp }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
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] || 'Неизвестное строение'} ${formatCoordinates(tile.x, tile.y)}`;
}
const terrainMap = {
'BLOCKED_TREE': 'Лесной участок',
'BLOCKED_STONE': 'Каменистый участок',
'EMPTY': 'Пустырь',
};
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} ${formatCoordinates(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: string) {
if (isSubmittingAdminAction.value) return;
isSubmittingAdminAction.value = true;
try {
// 1. Perform the requested admin action (e.g., reset, trigger tick)
const { error: actionError } = await useFetch(url, { method: 'POST' });
if (actionError.value) {
// If the action itself fails, throw to stop execution
throw actionError.value;
}
// 2. Refresh the main village data. This runs the core game logic
// on the backend and gets the updated state.
await refreshVillageData();
// 3. The `villageData` ref is now updated. If it contains a user object,
// we sync it with the global auth state to update the header.
if (villageData.value?.user) {
updateUser(villageData.value.user);
}
// 4. Refresh the event log to show any new events created by the action.
await refreshEvents();
} catch (e: any) {
console.error(`Failed to perform admin action at ${url}:`, e);
// Use the error's data object if it exists for a more specific message
alert(e.data?.statusMessage || e.message || 'An unexpected error occurred during the admin action.');
} finally {
isSubmittingAdminAction.value = false;
}
}
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
</script>
<style scoped>
.village-page-layout {
--tile-size: clamp(50px, 12vw, 65px);
display: flex;
flex-direction: column;
align-items: center;
}
.loading, .error-container {
margin-top: 50px;
font-size: 1.2em;
color: #555;
text-align: center;
}
.village-container {
display: flex;
justify-content: center;
width: 100%;
padding: 0 10px; /* Add some padding on mobile */
margin-top: 20px;
}
.tile {
width: var(--tile-size);
height: var(--tile-size);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--container-bg-color);
cursor: pointer;
transition: all 0.2s;
}
.tile.tile-blocked {
background-color: #f3f4f6;
}
.tile.tile-object {
background-color: #ecfdf5;
}
.tile:hover {
background-color: #fefce8;
border-color: #facc15;
}
.tile.selected {
border: 2px solid var(--primary-color);
box-shadow: 0 0 10px rgb(59 130 246 / 50%);
transform: scale(1.05);
}
.tile-content {
font-size: calc(var(--tile-size) * 0.4); /* Make emoji scale with tile */
}
/* Styles for the grid with labels */
.village-grid-wrapper {
display: grid;
grid-template-columns: 20px repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size)) 20px; /* Grid first, then labels */
gap: 4px;
border: 2px solid var(--border-color);
border-radius: 8px;
background-color: var(--background-color);
padding: 4px;
width: fit-content;
margin: 0 auto;
overflow-x: auto; /* Allow horizontal scroll if needed */
overflow-y: hidden; /* Prevent vertical scroll */
}
.empty-corner {
grid-column: 1;
grid-row: 8; /* Position at the bottom-left */
width: 20px;
height: 20px;
}
.col-labels {
grid-column: 2 / span 5;
grid-row: 8; /* Position labels at the bottom (8th row) */
display: flex;
justify-content: space-around;
align-items: center;
height: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.col-label {
width: var(--tile-size); /* Match new tile width */
text-align: center;
line-height: 20px;
}
.row-labels {
grid-column: 1;
grid-row: 1 / span 7; /* Position labels on the left of the grid */
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.row-label {
height: var(--tile-size); /* Match new tile height */
display: flex;
align-items: center;
justify-content: center;
line-height: 20px;
}
.village-grid {
grid-column: 2 / span 5;
grid-row: 1 / span 7; /* Position grid at the top */
display: grid;
grid-template-columns: repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size));
gap: 4px;
background-color: var(--background-color);
}
/* Overlay and other styles */
.tile-overlay-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 1000;
padding: 10px;
}
.tile-overlay-panel {
background-color: var(--background-color);
width: 100%;
max-width: 500px;
padding: 24px;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 16px;
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.tile-overlay-backdrop {
align-items: center;
}
.tile-overlay-panel {
border-radius: 15px;
}
}
.tile-overlay-panel h2 {
text-align: center;
}
.tile-description {
text-align: center;
margin-top: -10px;
color: var(--text-color-light);
font-style: italic;
}
.actions-container {
margin-top: 15px;
}
.actions-header {
text-align: center;
}
.build-section {
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.building-description {
font-size: 0.85em;
color: var(--text-color-light);
margin-top: 5px;
text-align: center;
}
.close-overlay-button {
width: 100%;
margin-top: 15px;
}
.bottom-content {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
margin-top: 24px;
}
.admin-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 15px;
border: 2px dashed var(--danger-color);
border-radius: 10px;
width: 100%;
max-width: 350px;
}
.admin-panel h3 {
margin: 0 0 10px 0;
color: var(--danger-color);
}
.admin-panel button {
width: 100%;
padding: 8px;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.admin-panel button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.event-log-container {
width: 100%;
max-width: 800px;
}
.event-log-container h2 {
text-align: center;
}
.table-responsive {
overflow-x: auto;
width: 100%;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.table td, .table th {
white-space: nowrap;
padding: 12px 15px;
vertical-align: middle;
}
.table .event-message {
white-space: normal;
min-width: 300px;
max-width: 500px;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.building-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 140px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: #fff;
text-align: center;
transition: box-shadow 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.building-card:not(.disabled):hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.building-card.disabled {
background-color: #f9fafb;
}
.building-icon {
font-size: 2.5em;
margin-bottom: 8px;
}
.building-card h5 {
margin: 0 0 8px 0;
font-size: 1.05em;
font-weight: 600;
}
.building-card .building-description {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-color-light);
flex-grow: 1;
margin-bottom: 16px;
}
.building-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: auto;
}
.building-footer .cost {
font-weight: 600;
font-size: 0.9em;
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(249, 250, 251, 0.85);
color: var(--danger-color);
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 10px;
border-radius: 8px;
cursor: not-allowed;
}
.disabled-overlay span {
font-size: 0.9em;
}
</style>