Browse Source

Add map layer controls with CSS filters and blend modes

- Add LayerFilterControl with expandable panel
- Include 9 base map options: OSM, CartoDB Positron/Voyager/Dark variants, Satellite, Transport (ÖPNV)
- Add Photoshop-style layer blending with overlay selection and 16 blend modes
- Add CSS filter controls: brightness, contrast, saturation, grayscale, hue-rotate, invert
- Improve combat skill buttons with horizontal layout and inline stats (damage, MP cost)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 4 weeks ago
parent
commit
b021db2428
  1. 568
      index.html

568
index.html

@ -2542,16 +2542,14 @@
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
border: 2px solid #e94560;
color: white;
padding: 8px 6px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
min-height: 70px;
gap: 8px;
}
.skill-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
@ -2564,16 +2562,26 @@
transform: none;
}
.skill-btn .skill-icon-wrapper {
margin-bottom: 3px;
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.skill-btn .skill-icon-wrapper img {
width: 28px;
height: 28px;
width: 32px;
height: 32px;
}
.skill-btn .skill-info {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.skill-btn .skill-name {
font-weight: bold;
font-size: 11px;
margin-bottom: 2px;
font-size: 12px;
line-height: 1.2;
}
.skill-btn .skill-cost {
@ -3318,6 +3326,173 @@
to { transform: translateX(100%); opacity: 0; }
}
/* Custom Layer & Filter Control */
.layer-filter-control {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
max-height: 80vh;
overflow-y: auto;
}
.layer-filter-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
background: white;
border-radius: 8px;
}
.layer-filter-toggle:hover {
background: #f0f0f0;
}
.layer-filter-panel {
display: none;
padding: 12px;
min-width: 220px;
}
.layer-filter-control.expanded .layer-filter-toggle {
display: none;
}
.layer-filter-control.expanded .layer-filter-panel {
display: block;
}
.layer-filter-section {
margin-bottom: 12px;
}
.layer-filter-section:last-child {
margin-bottom: 0;
}
.layer-filter-section-title {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
color: #666;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #eee;
}
.layer-filter-section label {
display: block;
padding: 4px 0;
cursor: pointer;
}
.layer-filter-section label:hover {
background: #f5f5f5;
margin: 0 -8px;
padding: 4px 8px;
border-radius: 4px;
}
.layer-filter-section input[type="radio"] {
margin-right: 8px;
}
.filter-slider-row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.filter-slider-row:last-child {
margin-bottom: 0;
}
.filter-slider-label {
width: 70px;
font-size: 11px;
color: #444;
}
.filter-slider-row input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: #ddd;
border-radius: 2px;
outline: none;
}
.filter-slider-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #4CAF50;
border-radius: 50%;
cursor: pointer;
}
.filter-slider-value {
width: 36px;
font-size: 11px;
color: #666;
text-align: right;
}
.filter-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.filter-checkbox-row label {
font-size: 11px;
color: #444;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.filter-reset-btn {
width: 100%;
margin-top: 10px;
padding: 6px 12px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.filter-reset-btn:hover {
background: #e0e0e0;
}
.blend-section select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
background: white;
}
.blend-section label {
display: block;
font-size: 11px;
color: #444;
margin-bottom: 4px;
}
.blend-section .blend-row {
margin-bottom: 10px;
}
.layer-filter-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
color: #999;
border-radius: 50%;
}
.layer-filter-close:hover {
background: #f0f0f0;
color: #333;
}
.layer-filter-panel {
position: relative;
}
</style>
<!-- Monster Animation Definitions -->
<script src="/animations.js"></script>
@ -4183,15 +4358,333 @@
maxNativeZoom: 19
});
// CartoDB free tile layers
const cartoPositron = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
const cartoPositronLabels = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
const cartoVoyager = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
const cartoVoyagerLabels = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
const cartoDark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
const cartoDarkLabels = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 22,
maxNativeZoom: 20
});
// ÖPNVKarte (Public Transport Map)
const opnvKarte = L.tileLayer('https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png', {
attribution: 'Map &copy; <a href="https://memomaps.de/">memomaps.de</a> CC-BY-SA, map data &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 22,
maxNativeZoom: 18
});
// Add street map by default
streetMap.addTo(map);
// Layer control
// Tile layer configurations (for creating overlay instances)
const tileConfigs = {
"OSM Street": { url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', options: { subdomains: 'abc', maxZoom: 22, maxNativeZoom: 19 } },
"Positron": { url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Positron (No Labels)": { url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Voyager": { url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Voyager (No Labels)": { url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Dark": { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Dark (No Labels)": { url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', options: { subdomains: 'abcd', maxZoom: 22, maxNativeZoom: 20 } },
"Satellite": { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', options: { maxZoom: 22, maxNativeZoom: 19 } },
"Transport (ÖPNV)": { url: 'https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png', options: { maxZoom: 22, maxNativeZoom: 18 } }
};
// Layer instances for base layer switching
const baseMaps = {
"Street Map": streetMap,
"Satellite": satellite
"OSM Street": streetMap,
"Positron": cartoPositronLabels,
"Positron (No Labels)": cartoPositron,
"Voyager": cartoVoyagerLabels,
"Voyager (No Labels)": cartoVoyager,
"Dark": cartoDarkLabels,
"Dark (No Labels)": cartoDark,
"Satellite": satellite,
"Transport (ÖPNV)": opnvKarte
};
// Custom Layer & Filter Control
const LayerFilterControl = L.Control.extend({
options: { position: 'bottomleft' },
onAdd: function(map) {
this._map = map;
this._currentLayer = streetMap;
this._overlayLayer = null;
this._filters = {
brightness: 100,
contrast: 100,
saturate: 100,
grayscale: 0,
hueRotate: 0,
invert: false
};
L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map);
this._blend = {
overlay: 'none',
opacity: 70,
mode: 'overlay'
};
const container = L.DomUtil.create('div', 'layer-filter-control');
// Build layer radio buttons HTML
let layerOptionsHtml = '';
Object.keys(baseMaps).forEach((name, i) => {
const checked = i === 0 ? 'checked' : '';
layerOptionsHtml += `<label><input type="radio" name="baseLayer" value="${name}" ${checked}>${name}</label>`;
});
// Build overlay layer options
let overlayOptionsHtml = '<option value="none">None</option>';
Object.keys(baseMaps).forEach(name => {
overlayOptionsHtml += `<option value="${name}">${name}</option>`;
});
// Blend modes
const blendModes = ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
'color-dodge', 'color-burn', 'hard-light', 'soft-light',
'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'];
let blendModeOptionsHtml = blendModes.map(m =>
`<option value="${m}" ${m === 'overlay' ? 'selected' : ''}>${m}</option>`
).join('');
container.innerHTML = `
<div class="layer-filter-toggle" title="Map Layers & Filters">🗺️</div>
<div class="layer-filter-panel">
<div class="layer-filter-close"></div>
<div class="layer-filter-section">
<div class="layer-filter-section-title">Base Layer</div>
${layerOptionsHtml}
</div>
<div class="layer-filter-section blend-section">
<div class="layer-filter-section-title">Layer Blend</div>
<div class="blend-row">
<label>Overlay Layer</label>
<select id="overlayLayer">${overlayOptionsHtml}</select>
</div>
<div class="blend-row">
<label>Blend Mode</label>
<select id="blendMode">${blendModeOptionsHtml}</select>
</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Opacity</span>
<input type="range" id="overlayOpacity" min="0" max="100" value="70">
<span class="filter-slider-value" id="overlayOpacityVal">70%</span>
</div>
</div>
<div class="layer-filter-section">
<div class="layer-filter-section-title">CSS Filters</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Brightness</span>
<input type="range" id="filterBrightness" min="0" max="200" value="100">
<span class="filter-slider-value" id="filterBrightnessVal">100%</span>
</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Contrast</span>
<input type="range" id="filterContrast" min="0" max="200" value="100">
<span class="filter-slider-value" id="filterContrastVal">100%</span>
</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Saturation</span>
<input type="range" id="filterSaturate" min="0" max="200" value="100">
<span class="filter-slider-value" id="filterSaturateVal">100%</span>
</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Grayscale</span>
<input type="range" id="filterGrayscale" min="0" max="100" value="0">
<span class="filter-slider-value" id="filterGrayscaleVal">0%</span>
</div>
<div class="filter-slider-row">
<span class="filter-slider-label">Hue Rotate</span>
<input type="range" id="filterHueRotate" min="0" max="360" value="0">
<span class="filter-slider-value" id="filterHueRotateVal"></span>
</div>
<div class="filter-checkbox-row">
<label><input type="checkbox" id="filterInvert"> Invert Colors</label>
</div>
<button class="filter-reset-btn" id="filterReset">Reset All</button>
</div>
</div>
`;
// Prevent map interactions
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
// Toggle panel
const toggle = container.querySelector('.layer-filter-toggle');
const closeBtn = container.querySelector('.layer-filter-close');
toggle.addEventListener('click', () => container.classList.add('expanded'));
closeBtn.addEventListener('click', () => container.classList.remove('expanded'));
// Layer switching
container.querySelectorAll('input[name="baseLayer"]').forEach(radio => {
radio.addEventListener('change', (e) => {
this._map.removeLayer(this._currentLayer);
this._currentLayer = baseMaps[e.target.value];
this._currentLayer.addTo(this._map);
this._applyFilters();
});
});
// Filter sliders
const sliderConfig = [
{ id: 'filterBrightness', key: 'brightness', unit: '%' },
{ id: 'filterContrast', key: 'contrast', unit: '%' },
{ id: 'filterSaturate', key: 'saturate', unit: '%' },
{ id: 'filterGrayscale', key: 'grayscale', unit: '%' },
{ id: 'filterHueRotate', key: 'hueRotate', unit: '°' }
];
sliderConfig.forEach(cfg => {
const slider = container.querySelector(`#${cfg.id}`);
const valueEl = container.querySelector(`#${cfg.id}Val`);
slider.addEventListener('input', (e) => {
this._filters[cfg.key] = parseInt(e.target.value);
valueEl.textContent = e.target.value + cfg.unit;
this._applyFilters();
});
});
// Invert checkbox
container.querySelector('#filterInvert').addEventListener('change', (e) => {
this._filters.invert = e.target.checked;
this._applyFilters();
});
// Overlay layer selection
container.querySelector('#overlayLayer').addEventListener('change', (e) => {
this._blend.overlay = e.target.value;
this._updateOverlayLayer();
});
// Blend mode selection
container.querySelector('#blendMode').addEventListener('change', (e) => {
this._blend.mode = e.target.value;
this._applyBlendMode();
});
// Overlay opacity slider
const opacitySlider = container.querySelector('#overlayOpacity');
const opacityVal = container.querySelector('#overlayOpacityVal');
opacitySlider.addEventListener('input', (e) => {
this._blend.opacity = parseInt(e.target.value);
opacityVal.textContent = e.target.value + '%';
this._applyBlendMode();
});
// Reset button
container.querySelector('#filterReset').addEventListener('click', () => {
// Reset filters
this._filters = { brightness: 100, contrast: 100, saturate: 100, grayscale: 0, hueRotate: 0, invert: false };
container.querySelector('#filterBrightness').value = 100;
container.querySelector('#filterBrightnessVal').textContent = '100%';
container.querySelector('#filterContrast').value = 100;
container.querySelector('#filterContrastVal').textContent = '100%';
container.querySelector('#filterSaturate').value = 100;
container.querySelector('#filterSaturateVal').textContent = '100%';
container.querySelector('#filterGrayscale').value = 0;
container.querySelector('#filterGrayscaleVal').textContent = '0%';
container.querySelector('#filterHueRotate').value = 0;
container.querySelector('#filterHueRotateVal').textContent = '0°';
container.querySelector('#filterInvert').checked = false;
this._applyFilters();
// Reset blend
this._blend = { overlay: 'none', opacity: 70, mode: 'overlay' };
container.querySelector('#overlayLayer').value = 'none';
container.querySelector('#blendMode').value = 'overlay';
container.querySelector('#overlayOpacity').value = 70;
container.querySelector('#overlayOpacityVal').textContent = '70%';
this._updateOverlayLayer();
});
this._container = container;
return container;
},
_updateOverlayLayer: function() {
// Remove existing overlay
if (this._overlayLayer) {
this._map.removeLayer(this._overlayLayer);
this._overlayLayer = null;
}
// Add new overlay if selected
if (this._blend.overlay !== 'none') {
const config = tileConfigs[this._blend.overlay];
if (config) {
// Create a new tile layer instance for overlay
this._overlayLayer = L.tileLayer(config.url, {
...config.options,
className: 'overlay-tile-layer'
});
this._overlayLayer.addTo(this._map);
// Apply blend mode after a short delay to ensure container exists
setTimeout(() => this._applyBlendMode(), 100);
}
}
},
_applyBlendMode: function() {
if (this._overlayLayer) {
const overlayContainer = this._overlayLayer.getContainer();
if (overlayContainer) {
overlayContainer.style.opacity = this._blend.opacity / 100;
overlayContainer.style.mixBlendMode = this._blend.mode;
}
}
},
_applyFilters: function() {
const tilePane = this._map.getPanes().tilePane;
const f = this._filters;
tilePane.style.filter = `
brightness(${f.brightness}%)
contrast(${f.contrast}%)
saturate(${f.saturate}%)
grayscale(${f.grayscale}%)
hue-rotate(${f.hueRotate}deg)
${f.invert ? 'invert(1)' : ''}
`.trim();
}
});
new LayerFilterControl().addTo(map);
// =====================
// FOG OF WAR SYSTEM
@ -14760,16 +15253,55 @@
if (baseSkill.type === 'utility') return;
const displayName = skillInfo?.displayName || hardcodedSkill?.name || dbSkill?.name || skillId;
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 28);
const iconHtml = renderSkillIcon(skillId, 'class', playerStats.class, 32);
const mpCost = hardcodedSkill?.mpCost || dbSkill?.mpCost || 0;
// Calculate skill effect for display
const skill = hardcodedSkill || dbSkill;
let effectText = '';
if (skill.type === 'damage') {
let dmg = 0;
if (skill.calculate) {
dmg = skill.calculate(playerStats.atk);
} else if (skill.basePower) {
dmg = Math.floor(playerStats.atk * (skill.basePower / 100));
}
const hits = skill.hits || skill.hitCount || 1;
effectText = dmg > 0 ? (hits > 1 ? `${hits}×${dmg}` : `${dmg} dmg`) : '';
} else if (skill.type === 'heal') {
let heal = 0;
if (skill.calculate) {
heal = skill.calculate(playerStats.maxHp);
} else if (skill.basePower) {
heal = Math.floor(playerStats.maxHp * (skill.basePower / 100));
}
effectText = heal > 0 ? `+${heal} HP` : '';
} else if (skill.type === 'restore') {
let restore = 0;
if (skill.calculate) {
restore = skill.calculate(playerStats.maxMp);
} else if (skill.basePower) {
restore = Math.floor(playerStats.maxMp * (skill.basePower / 100));
}
effectText = restore > 0 ? `+${restore} MP` : '';
} else if (skill.type === 'buff') {
effectText = skill.effect === 'dodge' ? 'Dodge' : (skill.effect || 'Buff');
} else if (skill.type === 'status') {
effectText = skill.effect || 'Status';
}
const mpText = mpCost > 0 ? `${mpCost} MP` : 'Free';
const costLine = effectText ? `${mpText} • ${effectText}` : mpText;
const btn = document.createElement('button');
btn.className = 'skill-btn';
btn.dataset.skillId = skillId;
btn.innerHTML = `
<span class="skill-icon-wrapper">${iconHtml}</span>
<span class="skill-info">
<span class="skill-name">${displayName}</span>
<span class="skill-cost ${mpCost === 0 ? 'free' : ''}">${mpCost > 0 ? mpCost + ' MP' : 'Free'}</span>
<span class="skill-cost ${mpCost === 0 ? 'free' : ''}">${costLine}</span>
</span>
`;
btn.onclick = () => executePlayerSkill(skillId);
skillsContainer.appendChild(btn);
@ -14783,9 +15315,11 @@
btn.dataset.skillId = 'admin_banish';
btn.style.borderColor = '#ff6b35';
btn.innerHTML = `
<span class="skill-icon-wrapper" style="font-size: 24px;">${adminSkill.icon}</span>
<span class="skill-icon-wrapper" style="font-size: 28px;">${adminSkill.icon}</span>
<span class="skill-info">
<span class="skill-name">${adminSkill.name}</span>
<span class="skill-cost free">Admin</span>
</span>
`;
btn.onclick = () => executePlayerSkill('admin_banish');
skillsContainer.appendChild(btn);

Loading…
Cancel
Save