636 lines
24 KiB
HTML
636 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>修仙世界地图编辑器</title>
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style>
|
||
body { margin: 0; overflow: hidden; background: #1a1a1a; color: #e0e0e0; }
|
||
#canvas-container {
|
||
width: 100%;
|
||
height: 100vh;
|
||
overflow: auto;
|
||
position: relative;
|
||
background: #333;
|
||
}
|
||
canvas {
|
||
display: block;
|
||
background: #000;
|
||
}
|
||
.sidebar {
|
||
position: fixed;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 300px;
|
||
background: #2d2d2d;
|
||
border-right: 1px solid #444;
|
||
z-index: 10;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.scroll-area {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
}
|
||
.item-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
margin-bottom: 4px;
|
||
transition: background 0.2s;
|
||
}
|
||
.item-btn:hover { background: #444; }
|
||
.item-btn.active { background: #3b82f6; color: white; }
|
||
.color-bar {
|
||
width: 4px;
|
||
align-self: stretch;
|
||
margin-right: 8px;
|
||
border-radius: 2px;
|
||
}
|
||
.preview-img {
|
||
width: 32px; height: 32px;
|
||
margin-right: 8px;
|
||
object-fit: cover;
|
||
border-radius: 2px;
|
||
background: #000;
|
||
}
|
||
.region-id {
|
||
font-size: 0.7rem;
|
||
color: #666;
|
||
font-family: monospace;
|
||
}
|
||
.bind-img {
|
||
width: 16px; height: 16px;
|
||
margin-left: auto;
|
||
opacity: 0.7;
|
||
}
|
||
.section-title {
|
||
font-size: 0.8rem;
|
||
color: #888;
|
||
margin-top: 10px;
|
||
margin-bottom: 5px;
|
||
padding-left: 5px;
|
||
text-transform: uppercase;
|
||
font-weight: bold;
|
||
}
|
||
/* Loader */
|
||
.loader {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.8);
|
||
display: flex; justify-content: center; align-items: center;
|
||
z-index: 100;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{% raw %}
|
||
<div id="app">
|
||
<!-- Loading Overlay -->
|
||
<div v-if="loading" class="loader">
|
||
<div class="text-white text-xl">正在加载资源...</div>
|
||
</div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar">
|
||
<div class="p-4 border-b border-gray-600 flex justify-between items-center">
|
||
<h1 class="font-bold text-lg">地图编辑器</h1>
|
||
<button @click="saveMap" class="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm">
|
||
保存
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tabs: 交换顺序,Region 在前 -->
|
||
<div class="flex border-b border-gray-600">
|
||
<div
|
||
class="flex-1 text-center py-2 cursor-pointer hover:bg-gray-700"
|
||
:class="{ 'bg-gray-600': mode === 'region' }"
|
||
@click="mode = 'region'">
|
||
区域 (Region)
|
||
</div>
|
||
<div
|
||
class="flex-1 text-center py-2 cursor-pointer hover:bg-gray-700"
|
||
:class="{ 'bg-gray-600': mode === 'tile' }"
|
||
@click="mode = 'tile'">
|
||
地形 (Tile)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Region List -->
|
||
<div v-if="mode === 'region'" class="scroll-area">
|
||
<div class="mb-2 px-2">
|
||
<button
|
||
class="w-full border border-red-400 text-red-400 py-1 rounded hover:bg-red-900"
|
||
:class="{ 'bg-red-900': currentRegion === null }"
|
||
@click="currentRegion = null">
|
||
橡皮擦 (清除区域)
|
||
</button>
|
||
</div>
|
||
<div
|
||
v-for="region in regions"
|
||
:key="region.id"
|
||
class="item-btn"
|
||
:class="{ active: currentRegion === region.id }"
|
||
@click="currentRegion = region.id">
|
||
<!-- 颜色条 -->
|
||
<div class="color-bar" :style="{ backgroundColor: region.color }"></div>
|
||
|
||
<!-- 绑定地形预览图 (主展示) -->
|
||
<img
|
||
v-if="region.bindTile"
|
||
:src="`/${region.bindTileType === 'sect' ? 'sects' : (region.bindTileType === 'city' ? 'cities' : 'tiles')}/${region.bindTileType === 'city' ? cityTilesMap[region.bindTile] : region.bindTile + '.png'}`"
|
||
class="preview-img"
|
||
>
|
||
<div v-else class="preview-img bg-gray-700"></div>
|
||
|
||
<div class="flex flex-col overflow-hidden">
|
||
<span class="text-sm font-bold truncate" :title="region.name">{{ region.name }}</span>
|
||
<span class="region-id">{{ region.id }} | {{ region.type }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tile List -->
|
||
<div v-if="mode === 'tile'" class="scroll-area">
|
||
<div class="section-title">基础地形</div>
|
||
<div
|
||
v-for="tile in tiles"
|
||
:key="tile"
|
||
class="item-btn"
|
||
:class="{ active: currentTile === tile && currentTileType === 'tile' }"
|
||
@click="selectTile(tile, 'tile')">
|
||
<img :src="`/tiles/${tile}.png`" class="preview-img">
|
||
<span>{{ tile }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-2 text-xs text-gray-500 border-t border-gray-600">
|
||
左键绘制 | 按住Shift框选<br>
|
||
滚轮缩放 | 右键拖动<br>
|
||
当前坐标: {{ hoverX }}, {{ hoverY }}<br>
|
||
<span v-if="isShiftDown" class="text-yellow-400">矩形工具已激活</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Canvas Area -->
|
||
<div id="canvas-container" ref="container" @contextmenu.prevent tabindex="0">
|
||
<canvas ref="canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, onMounted, reactive, computed, watch } = Vue;
|
||
|
||
createApp({
|
||
setup() {
|
||
// --- State ---
|
||
const loading = ref(true);
|
||
const mode = ref('region'); // 默认 Region 模式
|
||
const tiles = ref([]);
|
||
const sectTiles = ref([]);
|
||
const cityTiles = ref([]);
|
||
const cityTilesMap = ref({});
|
||
const regions = ref([]);
|
||
const savedMap = ref({});
|
||
|
||
const currentTile = ref('plain');
|
||
const currentTileType = ref('tile');
|
||
const currentRegion = ref(null);
|
||
|
||
const mapWidth = ref(100);
|
||
const mapHeight = ref(70);
|
||
const tileSize = 32;
|
||
|
||
const grid = reactive([]);
|
||
|
||
// Viewport
|
||
const scale = ref(1.0);
|
||
const offsetX = ref(320);
|
||
const offsetY = ref(20);
|
||
|
||
const hoverX = ref(0);
|
||
const hoverY = ref(0);
|
||
const isShiftDown = ref(false);
|
||
|
||
// Rect Tool
|
||
const dragStart = ref(null);
|
||
|
||
// Canvas refs
|
||
const canvas = ref(null);
|
||
const container = ref(null);
|
||
let ctx = null;
|
||
|
||
// Image Cache: name -> Image
|
||
const images = {};
|
||
|
||
// --- Initialization ---
|
||
onMounted(async () => {
|
||
await initData();
|
||
initGrid();
|
||
initCanvas();
|
||
initKeyboard();
|
||
requestAnimationFrame(drawLoop);
|
||
loading.value = false;
|
||
});
|
||
|
||
async function initData() {
|
||
try {
|
||
const res = await fetch('/api/init');
|
||
const data = await res.json();
|
||
tiles.value = data.tiles;
|
||
sectTiles.value = data.sectTiles;
|
||
cityTiles.value = data.cityTiles;
|
||
cityTilesMap.value = data.cityTilesMap;
|
||
regions.value = data.regions;
|
||
mapWidth.value = data.width;
|
||
mapHeight.value = data.height;
|
||
savedMap.value = data.savedMap || {};
|
||
|
||
// Preload images
|
||
const loadImg = (name, folder, filename) => {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
const finalName = filename || (name + '.png');
|
||
// Encode URI for Chinese characters in filenames
|
||
const encodedName = encodeURIComponent(finalName);
|
||
img.src = `/${folder}/${encodedName}`;
|
||
img.onload = () => { images[name] = img; resolve(); };
|
||
img.onerror = () => {
|
||
// Try fallback without encoding just in case
|
||
if (encodedName !== finalName) {
|
||
img.src = `/${folder}/${finalName}`;
|
||
} else {
|
||
console.warn('Missing img:', name); resolve();
|
||
}
|
||
};
|
||
});
|
||
};
|
||
|
||
// Load slices for large tiles (Sect/City/cave/ruin)
|
||
const loadLargeTileSlices = async (name, folder, ext = '.png') => {
|
||
const slicePromises = [0, 1, 2, 3].map(i => {
|
||
const sliceName = `${name}_${i}`;
|
||
return loadImg(sliceName, folder, `${sliceName}${ext}`);
|
||
});
|
||
await Promise.all(slicePromises);
|
||
};
|
||
|
||
const promises = [
|
||
// Normal tiles (including cave/ruin base, but we also need slices)
|
||
...data.tiles.map(t => loadImg(t, 'tiles')),
|
||
// Sect slices
|
||
...data.sectTiles.map(t => loadLargeTileSlices(t, 'sects')),
|
||
// City slices (handle jpg extension from cityTilesMap)
|
||
...data.cityTiles.map(t => {
|
||
const ext = data.cityTilesMap[t]?.split('.').pop() || 'jpg';
|
||
return loadLargeTileSlices(t, 'cities', '.' + ext);
|
||
}),
|
||
// cave/ruin slices
|
||
loadLargeTileSlices('cave', 'tiles'),
|
||
loadLargeTileSlices('ruin', 'tiles'),
|
||
];
|
||
await Promise.all(promises);
|
||
} catch (e) {
|
||
console.error("Failed to init", e);
|
||
alert("初始化失败,请查看控制台");
|
||
}
|
||
}
|
||
|
||
function initGrid() {
|
||
const total = mapWidth.value * mapHeight.value;
|
||
for (let y = 0; y < mapHeight.value; y++) {
|
||
for (let x = 0; x < mapWidth.value; x++) {
|
||
const key = `${x},${y}`;
|
||
const saved = savedMap.value[key];
|
||
grid.push({
|
||
x: x,
|
||
y: y,
|
||
t: saved?.t || 'plain',
|
||
r: saved?.r || null
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function selectTile(name, type) {
|
||
currentTile.value = name;
|
||
currentTileType.value = type;
|
||
}
|
||
|
||
function initKeyboard() {
|
||
window.addEventListener('keydown', e => {
|
||
if (e.key === 'Shift') isShiftDown.value = true;
|
||
});
|
||
window.addEventListener('keyup', e => {
|
||
if (e.key === 'Shift') isShiftDown.value = false;
|
||
});
|
||
}
|
||
|
||
// --- Canvas Logic ---
|
||
function initCanvas() {
|
||
const el = canvas.value;
|
||
ctx = el.getContext('2d');
|
||
resizeCanvas();
|
||
|
||
const cont = container.value;
|
||
|
||
let isDragging = false;
|
||
let isPanning = false;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
|
||
cont.addEventListener('mousedown', e => {
|
||
container.value.focus();
|
||
if (e.button === 2 || e.button === 1) {
|
||
isPanning = true;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
} else if (e.button === 0) {
|
||
isDragging = true;
|
||
if (isShiftDown.value) {
|
||
dragStart.value = { x: hoverX.value, y: hoverY.value };
|
||
} else {
|
||
paint(hoverX.value, hoverY.value);
|
||
}
|
||
}
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
if (isDragging && isShiftDown.value && dragStart.value) {
|
||
applyRectPaint();
|
||
}
|
||
isDragging = false;
|
||
isPanning = false;
|
||
dragStart.value = null;
|
||
});
|
||
|
||
cont.addEventListener('mousemove', e => {
|
||
updateHover(e);
|
||
|
||
if (isPanning) {
|
||
offsetX.value += e.clientX - lastX;
|
||
offsetY.value += e.clientY - lastY;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
}
|
||
|
||
if (isDragging) {
|
||
if (!isShiftDown.value) {
|
||
paint(hoverX.value, hoverY.value);
|
||
}
|
||
}
|
||
});
|
||
|
||
cont.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const zoomSpeed = 0.1;
|
||
const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed;
|
||
const newScale = Math.max(0.1, Math.min(5.0, scale.value + delta));
|
||
scale.value = newScale;
|
||
}, { passive: false });
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
canvas.value.width = window.innerWidth;
|
||
canvas.value.height = window.innerHeight;
|
||
}
|
||
|
||
function updateHover(e) {
|
||
const rect = canvas.value.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
|
||
const worldX = (mx - offsetX.value) / scale.value;
|
||
const worldY = (my - offsetY.value) / scale.value;
|
||
|
||
const gx = Math.max(0, Math.min(mapWidth.value - 1, Math.floor(worldX / tileSize)));
|
||
const gy = Math.max(0, Math.min(mapHeight.value - 1, Math.floor(worldY / tileSize)));
|
||
|
||
hoverX.value = gx;
|
||
hoverY.value = gy;
|
||
}
|
||
|
||
// Large tiles set (for quick lookup)
|
||
const largeTileSet = new Set(['cave', 'ruin']);
|
||
|
||
// Helper: Check if a tile is large (sect/city/cave/ruin)
|
||
function isLargeTile(tileName, tileType) {
|
||
if (tileType === 'sect' || tileType === 'city') return true;
|
||
if (largeTileSet.has(tileName)) return true;
|
||
return sectTiles.value.includes(tileName) || cityTiles.value.includes(tileName);
|
||
}
|
||
|
||
// Helper: Paint a 2x2 large tile using slices
|
||
function paintLargeTile(gx, gy, tileName, regionId = null) {
|
||
// Bounds check
|
||
if (gx + 1 >= mapWidth.value || gy + 1 >= mapHeight.value) return false;
|
||
|
||
const setCell = (tx, ty, tName, rId) => {
|
||
const tidx = ty * mapWidth.value + tx;
|
||
grid[tidx].t = tName;
|
||
if (rId !== null) grid[tidx].r = rId;
|
||
};
|
||
|
||
// TL=0, TR=1, BL=2, BR=3
|
||
setCell(gx, gy, `${tileName}_0`, regionId);
|
||
setCell(gx + 1, gy, `${tileName}_1`, regionId);
|
||
setCell(gx, gy + 1, `${tileName}_2`, regionId);
|
||
setCell(gx + 1, gy + 1, `${tileName}_3`, regionId);
|
||
|
||
return true;
|
||
}
|
||
|
||
function paint(gx, gy) {
|
||
if (gx < 0 || gx >= mapWidth.value || gy < 0 || gy >= mapHeight.value) return;
|
||
|
||
const idx = gy * mapWidth.value + gx;
|
||
const cell = grid[idx];
|
||
|
||
if (mode.value === 'tile') {
|
||
// Special handling for Large Tiles (Sect/City/cave/ruin) -> 2x2 slices
|
||
if (isLargeTile(currentTile.value, currentTileType.value)) {
|
||
paintLargeTile(gx, gy, currentTile.value);
|
||
} else {
|
||
// Normal 1x1 Tile
|
||
cell.t = currentTile.value;
|
||
}
|
||
} else {
|
||
// Region Mode
|
||
cell.r = currentRegion.value;
|
||
|
||
// Auto-paint bind tile if not eraser
|
||
if (currentRegion.value !== null) {
|
||
const region = regions.value.find(r => r.id === currentRegion.value);
|
||
if (region && region.bindTile) {
|
||
// If bound tile is large, paint 2x2 slices
|
||
if (isLargeTile(region.bindTile, region.bindTileType)) {
|
||
if (paintLargeTile(gx, gy, region.bindTile, currentRegion.value)) {
|
||
return; // Done painting 2x2
|
||
}
|
||
}
|
||
cell.t = region.bindTile;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyRectPaint() {
|
||
const x1 = Math.min(dragStart.value.x, hoverX.value);
|
||
const x2 = Math.max(dragStart.value.x, hoverX.value);
|
||
const y1 = Math.min(dragStart.value.y, hoverY.value);
|
||
const y2 = Math.max(dragStart.value.y, hoverY.value);
|
||
|
||
for (let y = y1; y <= y2; y++) {
|
||
for (let x = x1; x <= x2; x++) {
|
||
paint(x, y);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Render Loop ---
|
||
function drawLoop() {
|
||
if (!ctx) return;
|
||
|
||
const cvs = canvas.value;
|
||
const w = cvs.width;
|
||
const h = cvs.height;
|
||
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
ctx.save();
|
||
ctx.translate(offsetX.value, offsetY.value);
|
||
ctx.scale(scale.value, scale.value);
|
||
|
||
const startX = Math.floor(-offsetX.value / scale.value / tileSize);
|
||
const startY = Math.floor(-offsetY.value / scale.value / tileSize);
|
||
const endX = startX + Math.floor(w / scale.value / tileSize) + 2;
|
||
const endY = startY + Math.floor(h / scale.value / tileSize) + 2;
|
||
|
||
const rMap = {};
|
||
regions.value.forEach(r => rMap[r.id] = r);
|
||
|
||
// Draw Tiles & Region Borders
|
||
for (let y = Math.max(0, startY); y < Math.min(mapHeight.value, endY); y++) {
|
||
for (let x = Math.max(0, startX); x < Math.min(mapWidth.value, endX); x++) {
|
||
const idx = y * mapWidth.value + x;
|
||
const cell = grid[idx];
|
||
const px = x * tileSize;
|
||
const py = y * tileSize;
|
||
|
||
// 1. Draw Tile Image
|
||
const img = images[cell.t];
|
||
if (img) {
|
||
ctx.drawImage(img, px, py, tileSize, tileSize);
|
||
} else {
|
||
ctx.fillStyle = '#333';
|
||
ctx.fillRect(px, py, tileSize, tileSize);
|
||
}
|
||
|
||
// 2. Draw Region Border (Edge Detection)
|
||
if (cell.r !== null) {
|
||
const region = rMap[cell.r];
|
||
if (region) {
|
||
const getR = (tx, ty) => {
|
||
if (tx < 0 || tx >= mapWidth.value || ty < 0 || ty >= mapHeight.value) return null;
|
||
return grid[ty * mapWidth.value + tx].r;
|
||
};
|
||
|
||
ctx.lineWidth = 4;
|
||
ctx.lineCap = 'square';
|
||
ctx.strokeStyle = region.color;
|
||
ctx.beginPath();
|
||
|
||
// Top
|
||
if (getR(x, y - 1) !== cell.r) { ctx.moveTo(px, py); ctx.lineTo(px + tileSize, py); }
|
||
// Bottom
|
||
if (getR(x, y + 1) !== cell.r) { ctx.moveTo(px, py + tileSize); ctx.lineTo(px + tileSize, py + tileSize); }
|
||
// Left
|
||
if (getR(x - 1, y) !== cell.r) { ctx.moveTo(px, py); ctx.lineTo(px, py + tileSize); }
|
||
// Right
|
||
if (getR(x + 1, y) !== cell.r) { ctx.moveTo(px + tileSize, py); ctx.lineTo(px + tileSize, py + tileSize); }
|
||
|
||
ctx.stroke();
|
||
|
||
// Very light fill to indicate ownership
|
||
ctx.globalAlpha = 0.1;
|
||
ctx.fillStyle = region.color;
|
||
ctx.fillRect(px, py, tileSize, tileSize);
|
||
ctx.globalAlpha = 1.0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw Hover / Rect Preview
|
||
if (isShiftDown.value && dragStart.value) {
|
||
// Rect Preview
|
||
const x1 = Math.min(dragStart.value.x, hoverX.value);
|
||
const x2 = Math.max(dragStart.value.x, hoverX.value);
|
||
const y1 = Math.min(dragStart.value.y, hoverY.value);
|
||
const y2 = Math.max(dragStart.value.y, hoverY.value);
|
||
|
||
const px = x1 * tileSize;
|
||
const py = y1 * tileSize;
|
||
const rw = (x2 - x1 + 1) * tileSize;
|
||
const rh = (y2 - y1 + 1) * tileSize;
|
||
|
||
ctx.strokeStyle = '#ffff00';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(px, py, rw, rh);
|
||
ctx.fillStyle = 'rgba(255, 255, 0, 0.2)';
|
||
ctx.fillRect(px, py, rw, rh);
|
||
|
||
} else {
|
||
// Single Cell Hover
|
||
const px = hoverX.value * tileSize;
|
||
const py = hoverY.value * tileSize;
|
||
ctx.strokeStyle = 'white';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(px, py, tileSize, tileSize);
|
||
}
|
||
|
||
ctx.restore();
|
||
requestAnimationFrame(drawLoop);
|
||
}
|
||
|
||
// --- Actions ---
|
||
async function saveMap() {
|
||
const payload = {
|
||
grid: grid
|
||
};
|
||
try {
|
||
const res = await fetch('/api/save', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const json = await res.json();
|
||
if (json.status === 'success') {
|
||
alert('保存成功!');
|
||
} else {
|
||
alert('保存失败: ' + json.message);
|
||
}
|
||
} catch (e) {
|
||
alert('保存出错');
|
||
}
|
||
}
|
||
|
||
return {
|
||
loading, mode, tiles, sectTiles, cityTiles, cityTilesMap, regions,
|
||
currentTile, currentTileType, currentRegion,
|
||
saveMap,
|
||
hoverX, hoverY, isShiftDown,
|
||
selectTile,
|
||
canvas, container
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
{% endraw %}
|
||
</body>
|
||
</html> |