Files
yourpart3/frontend/src/views/admin/TaxiToolsView.vue
Torsten Schulz (local) ab8e12cbcd Änderung: Anpassung der Positionierung von Haus-Ecken im Taxi-Spiel
Änderungen:
- Anpassung der CSS-Positionen für die Haus-Ecken in der Datei TaxiToolsView.vue, um eine bessere visuelle Darstellung zu gewährleisten.
- Verschiebung der Ecken um 2 Pixel nach innen, um die Platzierung zu optimieren.

Diese Anpassungen verbessern die Benutzeroberfläche und die visuelle Klarheit der Hausplatzierung im Taxi-Minispiel.
2025-09-18 09:25:50 +02:00

1640 lines
53 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="contenthidden">
<div class="contentscroll">
<div class="admin-header">
<h1>{{ $t('admin.taxiTools.title') }}</h1>
<p>{{ $t('admin.taxiTools.description') }}</p>
</div>
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<!-- {{ $t('admin.taxiTools.mapEditor.title') }} Tab -->
<div v-if="activeTab === 'map-editor'" class="taxi-tools-admin">
<div class="section-header">
<h2>{{ $t('admin.taxiTools.mapEditor.title') }}</h2>
</div>
<div class="map-selection">
<div class="map-count">
<p>{{ $t('admin.taxiTools.mapEditor.availableMaps', { count: maps.length }) }}</p>
</div>
<div class="map-dropdown">
<select v-model="selectedMapId" @change="onMapSelect" class="map-select">
<option value="new">{{ $t('admin.taxiTools.mapEditor.newMap') }}</option>
<option
v-for="map in maps"
:key="map.id"
:value="map.id"
class="map-option"
>
{{ map.name }}
</option>
</select>
</div>
</div>
<!-- Map Details -->
<div v-if="selectedMapId !== 'new' && selectedMap" class="map-details">
<div class="details-header">
<h3>{{ selectedMap.name }}</h3>
</div>
<div class="details-content">
<div class="form-group">
<label for="mapName">{{ $t('admin.taxiTools.mapEditor.mapName') }}:</label>
<input
id="mapName"
v-model="mapForm.name"
type="text"
required
:placeholder="$t('admin.taxiTools.mapEditor.mapName')"
>
</div>
<div class="form-group">
<label for="mapDescription">{{ $t('admin.taxiTools.mapEditor.mapDescription') }}:</label>
<textarea
id="mapDescription"
v-model="mapForm.description"
required
:placeholder="$t('admin.taxiTools.mapEditor.mapDescription')"
rows="3"
></textarea>
</div>
<div class="form-group">
<label>{{ $t('admin.taxiTools.mapEditor.mapLayout') }}:</label>
<div class="map-editor-container">
<!-- Spielbrett (links) -->
<div class="game-board">
<div class="board-grid" :style="boardGridStyle">
<div
v-for="y in range(minY, maxY)"
:key="`row-${y}`"
class="board-row"
>
<div
v-for="x in range(minX, maxX)"
:key="`${x},${y}`"
class="board-cell"
:class="getCellClasses(getCellAtPosition(x, y), `${x},${y}`)"
@click="selectCell(`${x},${y}`)"
:title="`Position: (${x}, ${y})`"
>
<img
v-if="getCellAtPosition(x, y)?.tileType"
:src="`/images/taxi/map-${getCellAtPosition(x, y).tileType}.svg`"
:alt="getCellAtPosition(x, y).tileType"
class="tile-image"
/>
<div v-if="getCellAtPosition(x, y)?.extraHouses" class="house-overlay">
<div
v-for="(rotation, corner) in getCellAtPosition(x, y).extraHouses"
:key="corner"
class="house-square"
:class="cornerClass(corner)"
>
<div class="house-door" :class="doorClass(rotation)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tile-Palette (rechts) -->
<div class="tile-palette tool-panel">
<h4>{{ $t('admin.taxiTools.mapEditor.tilePalette') }}</h4>
<div class="tile-grid">
<div
v-for="tileType in availableTileTypes"
:key="tileType"
class="tile-option"
:class="{ 'selected': selectedTileType === tileType }"
@click="selectTileType(tileType)"
:title="tileType"
>
<img
:src="`/images/taxi/map-${tileType}.svg`"
:alt="tileType"
class="tile-image"
/>
</div>
</div>
</div>
<!-- Straßennamen (drittes Panel) -->
<div v-if="showStreetNamePanel" class="tool-panel street-names">
<h4>{{ $t('admin.taxiTools.mapEditor.streetNames') }}</h4>
<div class="panel-body">
<div class="form-group-mini" v-if="supportsHorizontalName">
<label class="arrow-label"></label>
<input type="text" :value="selectedCell?.streetNameH || ''"
@input="onChangeStreetName('h', $event.target.value)" />
</div>
<div class="form-group-mini" v-if="supportsVerticalName">
<label class="arrow-label"></label>
<input type="text" :value="selectedCell?.streetNameV || ''"
@input="onChangeStreetName('v', $event.target.value)" />
</div>
<div class="form-actions-mini" v-if="showContinueOtherButton">
<button type="button" class="btn btn-primary mini" @click="continueStreetNames">
{{ $t('admin.taxiTools.mapEditor.continueOther') }}
</button>
</div>
</div>
</div>
<!-- Zusätzliche Elemente (viertes Panel) -->
<div v-if="showExtraElementsPanel" class="tool-panel extra-elements">
<h4>{{ $t('admin.taxiTools.mapEditor.extraElements') }}</h4>
<div class="panel-body">
<div v-if="allowedHouseCorners.length" class="corner-chooser">
<button v-for="pos in allowedHouseCorners" :key="pos" class="corner-btn" @click="confirmHouseCorner(pos)">
{{ pos.toUpperCase() }}
</button>
</div>
<h5 v-if="pendingCorner" class="extra-subtitle">Haus</h5>
<div v-if="pendingCorner" class="extra-grid">
<div
v-for="dir in availableHouseDirections"
:key="dir"
class="extra-option"
@click="startPlaceHouse(directionToRotation(dir))"
:title="`Haus: Tür ${dir}`">
<div class="house-square">
<div class="house-door" :class="doorCssClass(dir)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" @click="deleteSelectedMap">
{{ $t('admin.taxiTools.mapEditor.delete') }}
</button>
<button type="button" class="btn btn-primary" @click="saveMap">
{{ $t('admin.taxiTools.mapEditor.update') }}
</button>
</div>
</div>
</div>
<!-- Map Form (nur für neue Maps) -->
<div v-if="selectedMapId === 'new'" class="map-form">
<div class="form-header">
<h3>{{ $t('admin.taxiTools.mapEditor.newMap') }}</h3>
</div>
<form @submit.prevent="saveMap">
<div class="form-group">
<label for="mapName">{{ $t('admin.taxiTools.mapEditor.mapName') }}:</label>
<input
id="mapName"
v-model="mapForm.name"
type="text"
required
:placeholder="$t('admin.taxiTools.mapEditor.mapName')"
>
</div>
<div class="form-group">
<label for="mapDescription">{{ $t('admin.taxiTools.mapEditor.mapDescription') }}:</label>
<textarea
id="mapDescription"
v-model="mapForm.description"
required
:placeholder="$t('admin.taxiTools.mapEditor.mapDescription')"
rows="3"
></textarea>
</div>
<div class="form-group">
<label>{{ $t('admin.taxiTools.mapEditor.mapLayout') }}:</label>
<div class="map-editor-container">
<!-- Spielbrett (links) -->
<div class="game-board">
<div class="board-grid" :style="boardGridStyle">
<div
v-for="y in range(minY, maxY)"
:key="`row-${y}`"
class="board-row"
>
<div
v-for="x in range(minX, maxX)"
:key="`${x},${y}`"
class="board-cell"
:class="getCellClasses(getCellAtPosition(x, y), `${x},${y}`)"
@click="selectCell(`${x},${y}`)"
:title="`Position: (${x}, ${y})`"
>
<img
v-if="getCellAtPosition(x, y)?.tileType"
:src="`/images/taxi/map-${getCellAtPosition(x, y).tileType}.svg`"
:alt="getCellAtPosition(x, y).tileType"
class="tile-image"
/>
<div v-if="getCellAtPosition(x, y)?.extraHouses" class="house-overlay">
<div
v-for="(rotation, corner) in getCellAtPosition(x, y).extraHouses"
:key="corner"
class="house-square"
:class="cornerClass(corner)"
>
<div class="house-door" :class="doorClass(rotation)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tile-Palette (rechts) -->
<div class="tile-palette tool-panel">
<h4>{{ $t('admin.taxiTools.mapEditor.tilePalette') }}</h4>
<div class="tile-grid">
<div
v-for="tileType in availableTileTypes"
:key="tileType"
class="tile-option"
:class="{ 'selected': selectedTileType === tileType }"
@click="selectTileType(tileType)"
:title="tileType"
>
<img
:src="`/images/taxi/map-${tileType}.svg`"
:alt="tileType"
class="tile-image"
/>
</div>
</div>
</div>
<!-- Straßennamen (drittes Panel) -->
<div v-if="showStreetNamePanel" class="tool-panel street-names">
<h4>{{ $t('admin.taxiTools.mapEditor.streetNames') }}</h4>
<div class="panel-body">
<div class="form-group-mini" v-if="supportsHorizontalName">
<label class="arrow-label"></label>
<input type="text" :value="selectedCell?.streetNameH || ''"
@input="onChangeStreetName('h', $event.target.value)" />
</div>
<div class="form-group-mini" v-if="supportsVerticalName">
<label class="arrow-label"></label>
<input type="text" :value="selectedCell?.streetNameV || ''"
@input="onChangeStreetName('v', $event.target.value)" />
</div>
<div class="form-actions-mini" v-if="showContinueOtherButton">
<button type="button" class="btn btn-primary mini" @click="continueStreetNames">
{{ $t('admin.taxiTools.mapEditor.continueOther') }}
</button>
</div>
</div>
</div>
<!-- Zusätzliche Elemente (viertes Panel) -->
<div v-if="showExtraElementsPanel" class="tool-panel extra-elements">
<h4>{{ $t('admin.taxiTools.mapEditor.extraElements') }}</h4>
<div class="panel-body">
<div v-if="allowedHouseCorners.length" class="corner-chooser">
<button
v-for="pos in allowedHouseCorners"
:key="pos"
class="corner-btn"
:title="pos.toUpperCase()"
@click="confirmHouseCorner(pos)">
<div class="corner-preview">
<span class="q" :class="{ active: pos === 'lo' }"></span>
<span class="q" :class="{ active: pos === 'ro' }"></span>
<span class="q" :class="{ active: pos === 'lu' }"></span>
<span class="q" :class="{ active: pos === 'ru' }"></span>
</div>
</button>
</div>
<h5 v-if="pendingCorner" class="extra-subtitle">Haus</h5>
<div v-if="pendingCorner" class="extra-grid">
<div
v-for="dir in availableHouseDirections"
:key="dir"
class="extra-option"
@click="startPlaceHouse(directionToRotation(dir))"
:title="`Haus: Tür ${dir}`">
<div class="house-square">
<div class="house-door" :class="doorCssClass(dir)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancelEdit">
{{ $t('admin.taxiTools.mapEditor.cancel') }}
</button>
<button type="submit" class="btn btn-primary">
{{ $t('admin.taxiTools.mapEditor.create') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Message Dialog -->
<MessageDialog ref="messageDialog" />
</div>
</template>
<script>
import SimpleTabs from '../../components/SimpleTabs.vue';
import MessageDialog from '../../dialogues/standard/MessageDialog.vue';
import apiClient from '../../utils/axios.js';
// Matrix: erlaubte Haus-Tür-Richtungen je Tile-Typ und Ecke
// Richtungen: bottom, right, top, left
const HOUSE_TYPE_MATRIX = {
horizontal: { lo: ['bottom'], ro: ['bottom'], lu: ['top'], ru: ['top'] },
vertical: { lo: ['right'], ro: ['left'], lu: ['right'], ru: ['left'] },
cross: { lo: ['bottom', 'right'], ro: ['bottom', 'left'], lu: ['top', 'right'], ru: ['top', 'left'] },
cornertopleft: { lo: ['bottom', 'right'], ro: ['left'], lu: ['top'], ru: [] },
cornertopright: { lo: ['right'], ro: ['bottom', 'right'], lu: [], ru: ['top'] },
cornerbottomleft: { lo: ['bottom'], ro: [], lu: ['top', 'right'], ru: ['left'] },
cornerbottomright: { lo: [], ro: ['bottom'], lu: ['right'], ru: ['top', 'left'] },
tup: { lo: ['bottom', 'right'], ro: ['bottom', 'left'], lu: ['top'], ru: ['top'] },
tdown: { lo: ['bottom'], ro: ['bottom'], lu: ['top', 'right'], ru: ['top', 'left'] },
tleft: { lo: ['bottom', 'right'], ro: ['left'], lu: ['top', 'right'], ru: ['left'] },
tright: { lo: ['right'], ro: ['bottom', 'left'], lu: ['right'], ru: ['top', 'left'] },
fuelhorizontal: { lo: [], ro: [], lu: ['top'], ru: ['top'] },
fuelvertical: { lo: ['right'], ro: [], lu: ['right'], ru: [] }
};
const DIRECTION_TO_ROTATION = { bottom: 0, right: 90, top: 180, left: 270 };
export default {
name: 'AdminTaxiToolsView',
components: {
SimpleTabs,
MessageDialog
},
data() {
return {
activeTab: 'map-editor',
tabs: [
{
value: 'map-editor',
label: this.$t('admin.taxiTools.mapEditor.title')
}
],
maps: [],
selectedMapId: 'new',
editingMap: null,
mapForm: {
name: '',
description: '',
// mapData entfernt Tiles werden separat gesendet
},
boardCells: {}, // { "x,y": { tileType: "horizontal", x: 0, y: 0 } }
availableTileTypes: [
// 1. Zeile: bottomright, tdown, horizontal, bottomleft
'cornerbottomright', 'tdown', 'horizontal', 'cornerbottomleft',
// 2. Zeile: topright, tup, fuelhorizontal, topleft
'cornertopright', 'tup', 'fuelhorizontal', 'cornertopleft',
// 3. Zeile: tright, tleft, vertical, fuelvertical
'tright', 'tleft', 'vertical', 'fuelvertical',
// 4. Zeile: cross
'cross'
],
selectedCellKey: null,
selectedTileType: null
,pendingHouse: null
,allowedHouseCorners: []
,pendingCorner: null
};
},
computed: {
selectedMap() {
if (this.selectedMapId === 'new') return null;
return this.maps.find(m => m.id === this.selectedMapId);
},
boardWidth() {
if (Object.keys(this.boardCells).length === 0) return 0;
return this.maxX - this.minX + 1;
},
boardHeight() {
if (Object.keys(this.boardCells).length === 0) return 0;
return this.maxY - this.minY + 1;
},
selectedCell() {
if (!this.selectedCellKey) return null;
return this.boardCells[this.selectedCellKey] || null;
},
showStreetNamePanel() {
// Panel nur anzeigen, wenn Zelle selektiert ist und der Tile-Typ Namen unterstützt
if (!this.selectedCell) return false;
const type = this.selectedCell.tileType;
return this.supportsHorizontal(type) || this.supportsVertical(type);
},
showExtraElementsPanel() {
// Nur sichtbar, wenn eine Zelle selektiert ist UND ein Tile-Typ gesetzt ist
return !!(this.selectedCell && this.selectedCell.tileType);
},
supportsHorizontalName() {
if (!this.selectedCell) return false;
return this.supportsHorizontal(this.selectedCell.tileType);
},
supportsVerticalName() {
if (!this.selectedCell) return false;
return this.supportsVertical(this.selectedCell.tileType);
},
showContinueOtherButton() {
if (!this.selectedCell) return false;
const hAllowed = this.supportsHorizontalName;
const vAllowed = this.supportsVerticalName;
if (!(hAllowed && vAllowed)) return false;
const hasH = !!this.selectedCell.streetNameH;
const hasV = !!this.selectedCell.streetNameV;
// genau einer gesetzt
return (hasH && !hasV) || (!hasH && hasV);
},
minX() {
if (Object.keys(this.boardCells).length === 0) return 0;
return Math.min(...Object.values(this.boardCells).map(cell => cell.x));
},
maxX() {
if (Object.keys(this.boardCells).length === 0) return 0;
return Math.max(...Object.values(this.boardCells).map(cell => cell.x));
},
minY() {
if (Object.keys(this.boardCells).length === 0) return 0;
return Math.min(...Object.values(this.boardCells).map(cell => cell.y));
},
maxY() {
if (Object.keys(this.boardCells).length === 0) return 0;
return Math.max(...Object.values(this.boardCells).map(cell => cell.y));
},
boardGridStyle() {
if (this.boardWidth === 0 || this.boardHeight === 0) {
return {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
border: '2px dashed #ccc',
background: '#f9f9f9'
};
}
return {
display: 'grid',
gridTemplateColumns: `repeat(${this.boardWidth}, 50px)`,
gridTemplateRows: `repeat(${this.boardHeight}, 50px)`,
gap: '2px'
};
},
// Verfügbare Haus-Tür-Richtungen anhand ausgewählter Ecke und Tile-Typ
availableHouseDirections() {
if (!this.pendingCorner || !this.selectedCell || !this.selectedCell.tileType) return [];
const entry = HOUSE_TYPE_MATRIX[this.selectedCell.tileType];
if (!entry) return [];
const dirs = entry[this.pendingCorner] || [];
return Array.isArray(dirs) ? dirs : [];
}
},
watch: {
selectedMapId: {
handler(newValue, oldValue) {
if (newValue !== oldValue) {
this.$nextTick(() => {
this.onMapSelect();
});
}
},
immediate: true
}
},
mounted() {
this.maps = [];
this.editingMap = null;
this.boardCells = {};
this.$nextTick(() => {
this.loadMaps();
this.initializeBoard();
});
},
methods: {
directionToRotation(dir) {
return DIRECTION_TO_ROTATION[dir] ?? 0;
},
doorCssClass(dir) {
switch (dir) {
case 'bottom': return 'door-bottom';
case 'right': return 'door-right';
case 'top': return 'door-top';
case 'left': return 'door-left';
default: return 'door-bottom';
}
},
supportsHorizontal(tileType) {
// Horizontalen Straßennamen erlauben bei: horizontal, cross, tleft, tright, tup, tdown, corns wo horizontaler Ast existiert
const allowed = ['horizontal', 'cross', 'tleft', 'tright', 'tup', 'tdown', 'cornertopleft', 'cornerbottomleft', 'cornertopright', 'cornerbottomright', 'fuelhorizontal'];
return allowed.includes(tileType);
},
supportsVertical(tileType) {
// Vertikalen Straßennamen erlauben bei: vertical, cross, tup, tdown, etc.
const allowed = ['vertical', 'cross', 'tleft', 'tright', 'tup', 'tdown', 'cornertopleft', 'cornerbottomleft', 'cornertopright', 'cornerbottomright', 'fuelvertical'];
return allowed.includes(tileType);
},
continueStreetNames() {
if (!this.selectedCell) return;
// Wenn nur einer der beiden Namen existiert, kopiere in das andere Feld
const hasH = !!this.selectedCell.streetNameH;
const hasV = !!this.selectedCell.streetNameV;
if (hasH && !hasV) {
const value = this.selectedCell.streetNameH;
this.onChangeStreetName('v', value);
} else if (!hasH && hasV) {
const value = this.selectedCell.streetNameV;
this.onChangeStreetName('h', value);
}
},
onChangeStreetName(dir, value) {
if (!this.selectedCell) return;
const key = dir === 'h' ? 'streetNameH' : 'streetNameV';
// Vue reaktiv: bestehendes Objekt erweitern
const updated = { ...(this.boardCells[this.selectedCellKey] || {}), [key]: value };
this.$set ? this.$set(this.boardCells, this.selectedCellKey, updated) : (this.boardCells[this.selectedCellKey] = updated);
// Namen entlang der Achse bis zu Abschluss-Tiles fortführen
this.propagateStreetNameChain(this.selectedCellKey, dir, value);
},
continueStreetName(dir) {
if (!this.selectedCell) return;
const name = dir === 'h' ? (this.selectedCell.streetNameH || '') : (this.selectedCell.streetNameV || '');
if (!name) return;
this.propagateStreetNameChain(this.selectedCellKey, dir, name);
},
// Führt Namen entlang der Achse fort, bis ein Abschluss-Tile erreicht ist
propagateStreetNameChain(startKey, dir, name) {
const start = this.boardCells[startKey];
if (!start || !start.tileType) return;
const { x, y } = start;
const keyField = dir === 'h' ? 'streetNameH' : 'streetNameV';
const walk = (sx, sy, sign) => {
let cx = sx;
let cy = sy;
while (true) {
if (dir === 'h') cx += (sign === 'right' ? 1 : -1); else cy += (sign === 'down' ? 1 : -1);
const key = `${cx},${cy}`;
const cell = this.boardCells[key];
if (!cell || !cell.tileType) break;
const status = this.classifyNeighbor(dir, sign, cell.tileType);
if (status === 'none') break;
const upd = { ...cell, [keyField]: name };
this.$set ? this.$set(this.boardCells, key, upd) : (this.boardCells[key] = upd);
if (status === 'terminal') break;
}
};
const dirs = this.allowedDirections(start.tileType);
if (dir === 'h') {
if (dirs.right) walk(x, y, 'right');
if (dirs.left) walk(x, y, 'left');
} else {
if (dirs.down) walk(x, y, 'down');
if (dirs.up) walk(x, y, 'up');
}
},
classifyNeighbor(dir, sign, type) {
const isCornerRight = (t) => /corner.*right$/.test(t);
const isCornerLeft = (t) => /corner.*left$/.test(t);
const isCornerTop = (t) => /cornertop.*$/.test(t);
const isCornerBottom= (t) => /cornerbottom.*$/.test(t);
if (dir === 'h') {
// horizontal folgen können: corner<xxx>right, horizontal, tdown, tup, cross, fuelhorizontal, sowie corner<xxx>left und tleft als Abschluss
if (sign === 'right') {
if (isCornerLeft(type) || type === 'tleft') return 'terminal';
if (isCornerRight(type) || ['horizontal','tdown','tup','cross','fuelhorizontal'].includes(type)) return 'continue';
return 'none';
} else { // left
if (isCornerRight(type) || type === 'tright') return 'terminal';
if (isCornerLeft(type) || ['horizontal','tdown','tup','cross','fuelhorizontal'].includes(type)) return 'continue';
return 'none';
}
} else {
// vertikal können folgen: cornerbottom<xxx>, vertical, tleft, tright, cross, fuelvertical, cornerup<xxx>
// Abschluss bei Richtung down: cornerup<xxx> und tup (wir setzen noch, führen aber nicht weiter)
if (sign === 'down') {
if (isCornerTop(type) || type === 'tup') return 'terminal';
if (isCornerBottom(type) || ['vertical','tleft','tright','cross','fuelvertical'].includes(type)) return 'continue';
return 'none';
} else { // up
if (isCornerBottom(type) || type === 'tdown') return 'terminal';
if (isCornerTop(type) || ['vertical','tleft','tright','cross','fuelvertical'].includes(type)) return 'continue';
return 'none';
}
}
},
allowedDirections(tileType) {
// Gibt zurück, in welche Richtungen dieses Tile Anschlüsse hat
switch (tileType) {
case 'cornertopleft': return { left: true, up: true, right: false, down: false };
case 'cornertopright': return { right: true, up: true, left: false, down: false };
case 'cornerbottomleft': return { left: true, down: true, right: false, up: false };
case 'cornerbottomright': return { right: true, down: true, left: false, up: false };
case 'horizontal':
case 'fuelhorizontal': return { left: true, right: true, up: false, down: false };
case 'vertical':
case 'fuelvertical': return { up: true, down: true, left: false, right: false };
case 'cross': return { left: true, right: true, up: true, down: true };
case 'tup': return { up: true, left: true, right: true, down: false };
case 'tdown': return { down: true, left: true, right: true, up: false };
case 'tleft': return { left: true, up: true, down: true, right: false };
case 'tright': return { right: true, up: true, down: true, left: false };
default: return { left: false, right: false, up: false, down: false };
}
},
propagateStreetNameFrom(startKey, dir, name) {
const keyField = dir === 'h' ? 'streetNameH' : 'streetNameV';
const step = dir === 'h' ? { dx: 1, dy: 0 } : { dx: 0, dy: 1 };
const oppositeStep = { dx: -step.dx, dy: -step.dy };
// in positive Richtung fortführen
this.walkAndSet(startKey, step, dir, keyField, name);
// in negative Richtung fortführen
this.walkAndSet(startKey, oppositeStep, dir, keyField, name);
},
walkAndSet(startKey, step, dir, keyField, name) {
const canContinue = (tileType, dir) => {
switch (tileType) {
case 'horizontal': return dir === 'h';
case 'vertical': return dir === 'v';
case 'cross': return true;
case 'fuelhorizontal': return dir === 'h';
case 'fuelvertical': return dir === 'v';
case 'cornertopleft':
case 'cornertopright':
case 'cornerbottomleft':
case 'cornerbottomright':
return false; // Kurve blockt die Fortführung
case 'tup': return dir === 'h';
case 'tdown': return dir === 'h';
case 'tleft': return dir === 'v';
case 'tright': return dir === 'v';
default: return false;
}
};
let [x, y] = startKey.split(',').map(Number);
while (true) {
x += step.dx; y += step.dy;
const key = `${x},${y}`;
const cell = this.boardCells[key];
if (!cell || !cell.tileType) break;
if (!canContinue(cell.tileType, dir)) break;
const updated = { ...cell, [keyField]: name };
this.$set ? this.$set(this.boardCells, key, updated) : (this.boardCells[key] = updated);
}
},
async loadMaps() {
try {
const user = this.$store.getters.user;
if (!user || !user.authCode) {
setTimeout(() => this.loadMaps(), 100);
return;
}
const response = await apiClient.get('/api/taxi-maps/maps');
this.maps = response.data.data || [];
} catch (error) {
console.error('Fehler beim Laden der Maps:', error);
}
},
async onMapSelect() {
if (this.selectedMapId === 'new') {
this.createMap();
} else {
const map = this.selectedMap;
if (map) {
await this.editMap(map);
} else {
console.warn('Keine Map gefunden für ID:', this.selectedMapId);
}
}
},
createMap() {
this.selectedMapId = 'new';
this.editingMap = null;
this.selectedCellKey = null;
this.selectedTileType = null;
this.mapForm = {
name: '',
description: '',
mapData: []
};
this.initializeBoard();
},
async editMap(map) {
this.editingMap = map;
this.selectedCellKey = null;
this.selectedTileType = null;
this.mapForm = {
name: map.name,
description: map.description,
};
// Falls Tiles aus Backend mitkommen, Board daraus erstellen
if (Array.isArray(map.tiles) && map.tiles.length > 0) {
this.boardCells = {};
for (const t of map.tiles) {
const key = `${t.x},${t.y}`;
this.boardCells[key] = { tileType: t.tileType, x: t.x, y: t.y };
if (t.meta && t.meta.house) {
// Legacy Einzel-Haus zu neuer Struktur migrieren
const corner = t.meta.house.corner || 'lo';
const rotation = t.meta.house.rotation || 0;
this.boardCells[key].extraHouses = { [corner]: rotation };
}
if (t.meta && t.meta.houses) {
// Neue Struktur: Mapping corner -> rotation
this.boardCells[key].extraHouses = { ...t.meta.houses };
}
}
// Straßennamen aus tileStreets übernehmen
if (Array.isArray(map.tileStreets)) {
for (const s of map.tileStreets) {
const key = `${s.x},${s.y}`;
if (this.boardCells[key]) {
this.boardCells[key] = {
...this.boardCells[key],
streetNameH: s.streetNameH?.name || null,
streetNameV: s.streetNameV?.name || null
};
}
}
}
} else {
this.initializeBoard();
}
},
async deleteSelectedMap() {
if (this.selectedMapId && this.selectedMapId !== 'new') {
await this.deleteMap(this.selectedMapId);
this.selectedMapId = 'new';
}
},
selectCell(key) {
// Wähle Position aus
this.selectedCellKey = this.selectedCellKey === key ? null : key;
// Reset temporärer Zustände
this.selectedTileType = null;
this.pendingHouse = null;
this.pendingCorner = null;
// Erlaubte Ecken vorberechnen
this.computeAllowedHouseCorners();
},
selectTileType(tileType) {
// Wähle Tile-Typ aus
this.selectedTileType = this.selectedTileType === tileType ? null : tileType;
// Wenn eine Position ausgewählt ist, platziere das Tile
if (this.selectedCellKey !== null) {
this.placeTile(this.selectedCellKey, tileType);
// Nach dem Setzen bleibt die Zelle ausgewählt
// Tile-Typ-Auswahl bleibt erhalten, um mehrere gleiche Tiles zu setzen
}
},
getCellPosition(cellKey) {
const [x, y] = cellKey.split(',').map(Number);
return { x, y };
},
placeTile(cellKey, tileType) {
const [x, y] = cellKey.split(',').map(Number);
this.boardCells[cellKey] = {
tileType: tileType,
x: x,
y: y
};
// Nach dem Setzen: Zelle ausgewählt lassen
this.selectedCellKey = cellKey;
// Erweitere das Board um benachbarte leere Zellen falls nötig
this.expandBoardIfNeeded(x, y, tileType);
// Erlaubte Haus-Ecken aktualisieren
this.computeAllowedHouseCorners();
},
startPlaceHouse(rotationDeg) {
// Neuer Flow: Rotation wählen, nachdem Ecke gewählt wurde
if (!this.selectedCellKey || !this.pendingCorner) return;
const cell = this.boardCells[this.selectedCellKey];
if (!cell || !cell.tileType) return;
const existing = cell.extraHouses || {};
const next = { ...existing, [this.pendingCorner]: rotationDeg };
const updated = { ...cell, extraHouses: next };
this.$set ? this.$set(this.boardCells, this.selectedCellKey, updated) : (this.boardCells[this.selectedCellKey] = updated);
this.pendingCorner = null;
},
confirmHouseCorner(corner) {
if (!this.selectedCellKey) return;
const cell = this.boardCells[this.selectedCellKey];
if (!cell || !cell.tileType) return;
this.pendingCorner = corner;
},
computeAllowedHouseCorners() {
if (!this.selectedCell || !this.selectedCell.tileType) {
this.allowedHouseCorners = [];
return;
}
this.allowedHouseCorners = this.getAllowedHouseCorners(this.selectedCell.tileType);
},
getAllowedHouseCorners(tileType) {
// lo=links oben, ro=rechts oben, lu=links unten, ru=rechts unten
switch (tileType) {
case 'cornerbottomright': return ['ro','lu','ru'];
case 'cornerbottomleft': return ['lo','lu','ru'];
case 'cornertopright': return ['lo','ro','ru'];
case 'cornertopleft': return ['lo','ro','lu'];
case 'fuelhorizontal': return ['ru','lu'];
case 'fuelvertical': return ['lo','lu'];
default: // horizontal, vertical, cross, tup, tdown, tleft, tright
return ['lo','ro','lu','ru'];
}
},
expandBoardIfNeeded(x, y, tileType) {
// Füge nur relevante Nachbarzellen basierend auf dem Tile-Typ hinzu
const neighborsToAdd = this.getRelevantNeighbors(x, y, tileType);
neighborsToAdd.forEach(neighbor => {
const key = `${neighbor.x},${neighbor.y}`;
if (!this.boardCells[key]) {
this.boardCells[key] = {
tileType: null,
x: neighbor.x,
y: neighbor.y
};
}
});
},
getRelevantNeighbors(x, y, tileType) {
const neighbors = [];
switch (tileType) {
case 'cornertopleft':
// Nur links und oben (Kurve von oben nach links)
neighbors.push({ x: x-1, y: y }, { x: x, y: y-1 });
break;
case 'cornertopright':
// Nur rechts und oben (Kurve von oben nach rechts)
neighbors.push({ x: x+1, y: y }, { x: x, y: y-1 });
break;
case 'cornerbottomleft':
// Nur links und unten (Kurve von unten nach links)
neighbors.push({ x: x-1, y: y }, { x: x, y: y+1 });
break;
case 'cornerbottomright':
// Nur rechts und unten (Kurve von unten nach rechts)
neighbors.push({ x: x+1, y: y }, { x: x, y: y+1 });
break;
case 'horizontal':
// Nur links und rechts (horizontal = nur horizontal anschließbar)
neighbors.push({ x: x-1, y: y }, { x: x+1, y: y });
break;
case 'vertical':
// Nur oben und unten (vertical = nur vertikal anschließbar)
neighbors.push({ x: x, y: y-1 }, { x: x, y: y+1 });
break;
case 'cross':
// Alle vier Richtungen (cross = überall anschließbar)
neighbors.push({ x: x-1, y: y }, { x: x+1, y: y }, { x: x, y: y-1 }, { x: x, y: y+1 });
break;
case 'fuelhorizontal':
// Nur links und rechts (wie horizontal)
neighbors.push({ x: x-1, y: y }, { x: x+1, y: y });
break;
case 'fuelvertical':
// Nur oben und unten (wie vertical)
neighbors.push({ x: x, y: y-1 }, { x: x, y: y+1 });
break;
case 'tup':
// Nur oben, links und rechts (T nach oben = 3 Anschlüsse)
neighbors.push({ x: x, y: y-1 }, { x: x-1, y: y }, { x: x+1, y: y });
break;
case 'tdown':
// Nur unten, links und rechts (T nach unten = 3 Anschlüsse)
neighbors.push({ x: x, y: y+1 }, { x: x-1, y: y }, { x: x+1, y: y });
break;
case 'tleft':
// Nur links, oben und unten (T nach links = 3 Anschlüsse)
neighbors.push({ x: x-1, y: y }, { x: x, y: y-1 }, { x: x, y: y+1 });
break;
case 'tright':
// Nur rechts, oben und unten (T nach rechts = 3 Anschlüsse)
neighbors.push({ x: x+1, y: y }, { x: x, y: y-1 }, { x: x, y: y+1 });
break;
default:
// Fallback: keine Nachbarn
return [];
}
return neighbors;
},
getCellClasses(cell, key) {
const classes = [];
if (this.selectedCellKey === key) {
classes.push('selected');
}
if (cell.tileType) {
classes.push('has-tile');
// Prüfe Tile-Kompatibilität
const compatibility = this.checkTileCompatibility(key, cell.tileType);
classes.push(compatibility);
} else {
classes.push('empty');
}
return classes;
},
checkTileCompatibility(cellKey, tileType) {
const [x, y] = cellKey.split(',').map(Number);
// Prüfe umgebende Tiles
const neighbors = {
top: this.getTileAt(x, y - 1),
right: this.getTileAt(x + 1, y),
bottom: this.getTileAt(x, y + 1),
left: this.getTileAt(x - 1, y)
};
// Wenn alle Nachbarn leer sind
if (Object.values(neighbors).every(tile => !tile)) {
return 'orange'; // Orange für leere Nachbarn
}
// Prüfe Kompatibilität mit vorhandenen Tiles
const isCompatible = this.isTileCompatible(tileType, neighbors);
return isCompatible ? 'green' : 'red';
},
getTileAt(x, y) {
const key = `${x},${y}`;
return this.boardCells[key]?.tileType;
},
getCellAtPosition(x, y) {
const key = `${x},${y}`;
const cell = this.boardCells[key];
if (cell) {
return cell;
}
// Erstelle leere Zelle für Grid-Position
return { tileType: null, x, y };
},
doorClass(deg) {
switch (deg) {
case 0: return 'door-bottom';
case 90: return 'door-right';
case 180: return 'door-top';
case 270: return 'door-left';
default: return 'door-bottom';
}
},
cornerClass(corner) {
switch (corner) {
case 'lo': return 'corner-lo';
case 'ro': return 'corner-ro';
case 'lu': return 'corner-lu';
case 'ru': return 'corner-ru';
default: return 'corner-lo';
}
},
range(start, end) {
const result = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
},
isTileCompatible(tileType, neighbors) {
// Vereinfachte Kompatibilitätsprüfung
// In der Realität würde hier die komplexe Logik aus streetCoordinates verwendet
return true; // Für jetzt immer kompatibel
},
initializeBoard() {
// Starte mit einem leeren Tile in der Mitte (0,0)
this.boardCells = {
'0,0': {
tileType: null,
x: 0,
y: 0
}
};
},
createBoardFromData(mapData) {
this.boardCells = {};
// Konvertiere 2D Array zu Board-Zellen
for (let row = 0; row < mapData.length; row++) {
for (let col = 0; col < mapData[row].length; col++) {
const tileType = mapData[row][col];
if (tileType) {
const key = `${col},${row}`;
this.boardCells[key] = {
tileType: tileType,
x: col,
y: row
};
}
}
}
// Erweitere Board um leere Nachbarzellen
Object.keys(this.boardCells).forEach(key => {
const [x, y] = key.split(',').map(Number);
const cell = this.boardCells[key];
if (cell.tileType) {
this.expandBoardIfNeeded(x, y, cell.tileType);
}
});
},
generateMapData() {
if (Object.keys(this.boardCells).length === 0) {
return [];
}
const mapData = [];
for (let y = this.minY; y <= this.maxY; y++) {
const row = [];
for (let x = this.minX; x <= this.maxX; x++) {
const key = `${x},${y}`;
const cell = this.boardCells[key];
row.push(cell?.tileType || null);
}
mapData.push(row);
}
return mapData;
},
cancelEdit() {
this.editingMap = null;
this.selectedMapId = 'new';
this.selectedCellKey = null;
this.selectedTileType = null;
this.mapForm = {
name: '',
description: '',
mapData: []
};
this.initializeBoard();
},
async saveMap() {
try {
// Tiles in Array-Form bringen
const tiles = Object.values(this.boardCells)
.filter(c => !!c.tileType)
.map(c => ({
x: c.x,
y: c.y,
tileType: c.tileType,
meta: c.extraHouses ? { houses: { ...c.extraHouses } } : null
}));
const tileStreetNames = this.collectTileStreetNames();
const mapData = {
...this.mapForm,
width: this.boardWidth,
height: this.boardHeight,
tileSize: 50,
mapTypeId: 1,
tiles,
tileStreetNames
};
let savedMap;
const isUpdate = this.selectedMapId !== 'new';
if (isUpdate) {
// Map aktualisieren
const response = await apiClient.put(`/api/taxi-maps/maps/${this.selectedMapId}`, mapData);
savedMap = response.data.data;
} else {
// Neue Map erstellen
const response = await apiClient.post('/api/taxi-maps/maps', mapData);
savedMap = response.data.data;
}
this.editingMap = null;
this.selectedMapId = 'new';
this.selectedCellKey = null;
this.selectedTileType = null;
this.loadMaps();
// Erfolgsmeldung anzeigen
const message = isUpdate ? 'admin.taxiTools.mapEditor.updateSuccess' : 'admin.taxiTools.mapEditor.createSuccess';
this.$refs.messageDialog.open(`tr:${message}`);
} catch (error) {
console.error('Fehler beim Speichern der Map:', error);
alert('Fehler beim Speichern der Map');
}
},
collectTileStreetNames() {
const result = {};
for (const [key, cell] of Object.entries(this.boardCells)) {
if (!cell.tileType) continue;
const { streetNameH, streetNameV } = cell;
if (streetNameH || streetNameV) {
result[key] = { streetNameH: streetNameH || null, streetNameV: streetNameV || null };
}
}
return result;
},
async deleteMap(mapId) {
if (confirm('Möchtest du diese Map wirklich löschen?')) {
try {
await apiClient.delete(`/api/taxi-maps/maps/${mapId}`);
this.loadMaps();
// Erfolgsmeldung anzeigen
this.$refs.messageDialog.open('tr:admin.taxiTools.mapEditor.deleteSuccess');
} catch (error) {
console.error('Fehler beim Löschen der Map:', error);
}
}
}
}
};
</script>
<style scoped>
.admin-header {
text-align: center;
margin-bottom: 30px;
}
.admin-header h1 {
color: #F9A22C;
margin-bottom: 10px;
}
.admin-header p {
color: #666;
font-size: 16px;
}
.taxi-tools-admin {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.section-header {
margin-bottom: 30px;
text-align: center;
}
.section-header h2 {
color: #F9A22C;
font-size: 24px;
margin-bottom: 10px;
}
/* Map Selection */
.map-selection {
margin-bottom: 30px;
text-align: center;
}
.map-count {
margin-bottom: 15px;
}
.map-count p {
font-size: 18px;
color: #333;
font-weight: 500;
}
.map-dropdown {
display: flex;
justify-content: center;
}
.map-select {
padding: 10px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
min-width: 300px;
cursor: pointer;
}
.map-select:focus {
outline: none;
border-color: #F9A22C;
}
/* Map Details & Form */
.map-details,
.map-form {
background: white;
border: 2px solid #ddd;
border-radius: 12px;
padding: 25px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.details-header,
.form-header {
margin-bottom: 25px;
text-align: center;
}
.details-header h3,
.form-header h3 {
color: #F9A22C;
font-size: 22px;
margin: 0;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 16px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #F9A22C;
}
/* Map Editor Container */
.map-editor-container {
display: flex;
gap: 20px;
margin-top: 10px;
}
/* Game Board (links) */
.game-board {
flex: 1;
max-width: 600px;
}
.board-grid {
border: 2px solid #333;
padding: 10px;
background: #f5f5f5;
min-height: 200px;
}
.board-row {
display: contents;
}
.board-cell {
width: 50px;
height: 50px;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: white;
transition: all 0.2s ease;
position: relative;
}
.board-cell:hover {
border-color: #F9A22C;
transform: scale(1.05);
}
.board-cell.selected {
border: 3px solid #ffd700; /* Gelb für ausgewählte Zelle */
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
.board-cell.empty {
background: #f9f9f9;
}
.board-cell.has-tile {
background: #e8f5e8;
}
.board-cell.green {
border-color: #4CAF50;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.5);
}
.board-cell.red {
border-color: #F44336;
box-shadow: 0 0 5px rgba(244, 67, 54, 0.5);
}
.board-cell.orange {
border-color: #FF9800;
box-shadow: 0 0 5px rgba(255, 152, 0, 0.5);
}
.tile-image {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Tile Palette (rechts) */
.tile-palette {
flex: 0 0 300px;
}
/* Vereinheitlichte Tool-Panels rechts */
.tool-panel {
flex: 0 0 220px;
}
.tool-panel h4 {
margin: 0 0 8px 0; /* weniger Abstand oben/unten */
color: #F9A22C;
font-size: 18px;
}
.extra-grid { display: flex; gap: 8px; align-items: center; }
.extra-option {
width: 20px;
height: 20px;
border: 1px solid #ddd;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
cursor: pointer;
transition: transform 0.15s ease;
}
.extra-option:hover { transform: scale(1.05); }
.house-square { width: 10px; height: 10px; background: #d0d0d0; border: 1px solid #aaa; position: relative; }
.house-door { position: absolute; background: #000; }
.door-top { top: -1px; left: calc(50% - 2.5px); width: 5px; height: 2px; }
.door-bottom { bottom: -1px; left: calc(50% - 2.5px); width: 5px; height: 2px; }
.door-left { left: -1px; top: calc(50% - 2.5px); width: 2px; height: 5px; }
.door-right { right: -1px; top: calc(50% - 2.5px); width: 2px; height: 5px; }
.house-overlay { position: absolute; inset: 0; pointer-events: none; }
.house-square.corner-lo { position: absolute; top: 3px; left: 3px; }
.house-square.corner-ro { position: absolute; top: 3px; right: 3px; }
.house-square.corner-lu { position: absolute; bottom: 3px; left: 3px; }
.house-square.corner-ru { position: absolute; bottom: 3px; right: 3px; }
.corner-chooser { margin-top: 8px; display: flex; gap: 6px; }
.corner-btn { padding: 3px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
.corner-btn:hover { background: #eee; }
.corner-preview { width: 18px; height: 18px; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.corner-preview .q { width: 100%; height: 100%; background: #000; display: block; }
.corner-preview .q.active { background: #d60000; }
.panel-body.placeholder {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 8px;
color: #999;
text-align: center;
}
.form-group-mini {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.arrow-label {
width: 28px;
text-align: center;
font-weight: 600;
}
.form-group-mini input {
flex: 1;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
.btn.mini {
padding: 6px 10px;
font-size: 12px;
}
.tile-palette h4 {
margin-bottom: 15px;
color: #F9A22C;
font-size: 18px;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(4, 50px);
grid-template-rows: repeat(4, 50px);
gap: 5px;
border: 2px solid #ddd;
padding: 6px; /* weniger Innenabstand, damit oben/unten kompakter */
background: #f9f9f9;
border-radius: 8px;
}
.tile-option {
width: 50px;
height: 50px;
border: 2px solid #ddd;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: white;
transition: all 0.2s ease;
}
.tile-option:hover {
border-color: #F9A22C;
transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tile-option.selected {
border-color: #F9A22C;
background: #fff3cd;
box-shadow: 0 0 10px rgba(249, 162, 44, 0.5);
}
/* Form Actions */
.form-actions {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: #F9A22C;
color: white;
}
.btn-primary:hover {
background: #e8941f;
transform: translateY(-2px);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-2px);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
transform: translateY(-2px);
}
/* Responsive Design */
@media (max-width: 1200px) {
.map-editor-container {
flex-direction: column;
}
.tile-palette {
flex: none;
max-width: none;
}
.tile-grid {
grid-template-columns: repeat(4, 50px);
grid-template-rows: repeat(4, 50px);
justify-content: center;
}
}
@media (max-width: 768px) {
.taxi-tools-admin {
padding: 15px;
}
.form-actions {
flex-direction: column;
align-items: center;
}
.map-select {
min-width: 250px;
}
.board-grid {
grid-template-columns: repeat(auto-fit, 40px) !important;
grid-template-rows: repeat(auto-fit, 40px) !important;
}
.board-cell {
width: 40px;
height: 40px;
}
.tile-grid {
grid-template-columns: repeat(4, 40px);
grid-template-rows: repeat(4, 40px);
}
.tile-option {
width: 40px;
height: 40px;
}
}
</style>