| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>The Marauder's Map</title> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script> |
| <style> |
| html, body { |
| width: 100%; |
| height: 100%; |
| margin: 0; |
| background: #000; |
| } |
| |
| .map-container { |
| width: 1440px; |
| height: 900px; |
| position: relative; |
| background-image: url('./map-bg.png'); |
| background-size: cover; |
| background-position: center; |
| } |
| |
| #leaflet-map { |
| width: 100%; |
| height: 100%; |
| position: absolute; |
| top: 0; |
| left: 0; |
| background: transparent; |
| } |
| |
| .leaflet-tile-container img { |
| opacity: 0.4 !important; |
| filter: grayscale(100%) !important; |
| } |
| |
| .map .mask { |
| width: 100%; |
| height: 100%; |
| background-image: url("./cloud.png"); |
| mix-blend-mode: lighten; |
| animation: 4s linear 0s magic forwards; |
| position: absolute; |
| top: 0; |
| left: 0; |
| } |
| |
| .track { |
| position: absolute; |
| top: 0; |
| width: 1440px; |
| height: 900px; |
| mix-blend-mode: multiply; |
| opacity: .7; |
| pointer-events: none; |
| } |
| |
| .footprint { |
| position: absolute; |
| pointer-events: none; |
| } |
| |
| .footprint .foot { |
| position: absolute; |
| width: 10px; |
| height: 22px; |
| background-image: url('./footprints.png'); |
| background-size: 40px; |
| background-repeat: no-repeat; |
| background-position-x: 10px; |
| animation: 1s linear 0s footsteps forwards; |
| } |
| |
| .footprint .foot::after { |
| display: block; |
| content: ''; |
| width: 100%; |
| height: 100%; |
| background-image: url('./footprints-cloud.png'); |
| background-repeat: no-repeat; |
| background-size: 20px 22px; |
| mix-blend-mode: lighten; |
| animation: 8s linear 1s footHide forwards; |
| opacity: 0; |
| } |
| |
| .footprint.last .foot::after { |
| animation-play-state: paused; |
| } |
| |
| @keyframes footHide { |
| 0% { |
| opacity: 0; |
| } |
| 25% { |
| opacity: 1; |
| filter: brightness(1); |
| } |
| 100% { |
| opacity: 1; |
| filter: brightness(10); |
| } |
| } |
| |
| .footprint .foot.right::after { |
| background-position-x: -10px; |
| } |
| |
| @keyframes footsteps { |
| 0% { |
| background-position-x: 0px; |
| } |
| 25% { |
| background-position-x: 0px; |
| } |
| 25.1% { |
| background-position-x: -10px; |
| } |
| 50% { |
| background-position-x: -10px; |
| } |
| 50.1% { |
| background-position-x: -20px; |
| } |
| 75% { |
| background-position-x: -20px; |
| } |
| 75.1% { |
| background-position-x: -30px; |
| } |
| 100% { |
| background-position-x: -30px; |
| } |
| } |
| |
| .footprint .foot.left { |
| left: -5px; |
| top: 7px; |
| } |
| |
| .footprint .foot.right { |
| left: 5px; |
| top: -7px; |
| background-position-y: -22px; |
| animation-delay: 1s; |
| } |
| |
| @keyframes magic { |
| 0% { |
| filter: brightness(10); |
| opacity: 1; |
| } |
| 70% { |
| filter: brightness(8); |
| opacity: 1; |
| } |
| 100% { |
| filter: brightness(0); |
| opacity: 0; |
| } |
| } |
| |
| .name-banner { |
| position: absolute; |
| width: 160px; |
| height: 40px; |
| background-image: url('./banner.svg'); |
| background-repeat: no-repeat; |
| background-size: 160px 40px; |
| text-align: center; |
| line-height: 30px; |
| font-family: 'Apple Chancery', cursive; |
| z-index: 1000; |
| animation: 1s linear 0s nameShow forwards; |
| color: #3c2a1e; |
| text-shadow: 0 0 2px rgba(255, 255, 255, 0.5); |
| } |
| |
| @keyframes nameShow { |
| from { |
| opacity: 0; |
| } |
| to { |
| opacity: 1; |
| } |
| } |
| |
| .user-controls { |
| position: fixed; |
| top: 20px; |
| right: 20px; |
| z-index: 1000; |
| background: rgba(222, 184, 135, 0.9); |
| padding: 15px; |
| border-radius: 8px; |
| border: 2px solid #8b4513; |
| } |
| |
| .user-controls input { |
| padding: 8px 12px; |
| margin-right: 10px; |
| border: 1px solid #8b4513; |
| border-radius: 4px; |
| font-family: 'Apple Chancery', cursive; |
| background: rgba(255, 255, 255, 0.9); |
| } |
| |
| .user-controls button { |
| padding: 8px 16px; |
| background: #8b4513; |
| color: #fff; |
| border: none; |
| border-radius: 4px; |
| cursor: pointer; |
| font-family: 'Apple Chancery', cursive; |
| transition: background-color 0.3s; |
| } |
| |
| .user-controls button:hover { |
| background: #654321; |
| } |
| |
| .leaflet-control-attribution { |
| display: none; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="map-container"> |
| <div class="map"> |
| <div id="mapMask" class="mask"></div> |
| </div> |
| <div id="leaflet-map"></div> |
| <div id="track" class="track"></div> |
| <div class="user-controls"> |
| <input type="text" id="username" placeholder="输入你的名字" maxlength="20"> |
| <button onclick="updateUsername()">更新名字</button> |
| </div> |
| </div> |
|
|
| <script> |
| let map; |
| let socket; |
| let username = '陌生访客_' + Math.floor(Math.random() * 1000); |
| let userMarker; |
| let otherUsers = {}; |
| let lastPosition = null; |
| let lastUpdateTime = 0; |
| const updateInterval = 1000; |
| let isInitialLoad = true; |
| |
| function initSocket() { |
| socket = io(); |
| |
| socket.on('connect', () => { |
| console.log('Connected to server'); |
| }); |
| |
| socket.on('disconnect', () => { |
| console.log('Disconnected from server'); |
| }); |
| |
| socket.on('users_update', (users) => { |
| updateOtherUsers(users); |
| }); |
| |
| socket.on('user_disconnected', (data) => { |
| removeUser(data.username); |
| }); |
| } |
| |
| function createFootprint() { |
| const footprint = document.createElement('div'); |
| footprint.className = 'footprint'; |
| |
| const footLeft = document.createElement('div'); |
| const footRight = document.createElement('div'); |
| |
| footLeft.className = 'foot left'; |
| footRight.className = 'foot right'; |
| |
| footprint.appendChild(footLeft); |
| footprint.appendChild(footRight); |
| |
| return footprint; |
| } |
| |
| function getAngle(p0, p1) { |
| if (!p0 || !p1) return 0; |
| const deltaX = p1.lng - p0.lng; |
| const deltaY = p1.lat - p0.lat; |
| return Math.atan2(deltaY, deltaX); |
| } |
| |
| function createUserIcon(name) { |
| const icon = document.createElement('div'); |
| icon.className = 'name-banner'; |
| icon.textContent = name; |
| |
| return L.divIcon({ |
| className: 'user-marker', |
| html: icon.outerHTML, |
| iconSize: [160, 40], |
| iconAnchor: [80, 20] |
| }); |
| } |
| |
| function placeFootprint(position, angle) { |
| const footprint = createFootprint(); |
| const point = map.latLngToLayerPoint([position.lat, position.lng]); |
| |
| footprint.style.left = `${point.x}px`; |
| footprint.style.top = `${point.y}px`; |
| footprint.style.transform = `rotate(${angle + Math.PI/2}rad)`; |
| |
| document.querySelector('#track').appendChild(footprint); |
| |
| setTimeout(() => { |
| footprint.remove(); |
| }, 15000); |
| } |
| |
| function updateUserPosition(position) { |
| const now = Date.now(); |
| if (now - lastUpdateTime < updateInterval) return; |
| lastUpdateTime = now; |
| |
| const { latitude, longitude } = position.coords; |
| const newPosition = { lat: latitude, lng: longitude }; |
| |
| if (!userMarker) { |
| userMarker = L.marker([latitude, longitude], { |
| icon: createUserIcon(username) |
| }).addTo(map); |
| map.setView([latitude, longitude], 16); |
| } else { |
| userMarker.setLatLng([latitude, longitude]); |
| |
| if (!isInitialLoad && lastPosition) { |
| const angle = getAngle(lastPosition, newPosition); |
| placeFootprint(newPosition, angle); |
| } |
| } |
| |
| isInitialLoad = false; |
| lastPosition = newPosition; |
| |
| socket.emit('update_location', { |
| username: username, |
| location: newPosition |
| }); |
| } |
| |
| function updateOtherUsers(users) { |
| for (const [name, data] of Object.entries(users)) { |
| if (name === username) continue; |
| |
| const newPos = { lat: data.location.lat, lng: data.location.lng }; |
| |
| if (!otherUsers[name]) { |
| otherUsers[name] = { |
| marker: L.marker([newPos.lat, newPos.lng], { |
| icon: createUserIcon(name) |
| }).addTo(map), |
| lastPosition: null |
| }; |
| } else { |
| const oldPos = otherUsers[name].lastPosition; |
| otherUsers[name].marker.setLatLng([newPos.lat, newPos.lng]); |
| |
| if (oldPos) { |
| const angle = getAngle(oldPos, newPos); |
| placeFootprint(newPos, angle); |
| } |
| } |
| |
| otherUsers[name].lastPosition = newPos; |
| } |
| |
| |
| for (const name of Object.keys(otherUsers)) { |
| if (!users[name]) { |
| map.removeLayer(otherUsers[name].marker); |
| delete otherUsers[name]; |
| } |
| } |
| } |
| |
| function removeUser(username) { |
| if (otherUsers[username]) { |
| map.removeLayer(otherUsers[name].marker); |
| delete otherUsers[username]; |
| } |
| } |
| |
| function updateUsername() { |
| const newName = document.getElementById('username').value.trim(); |
| if (newName) { |
| const oldUsername = username; |
| username = newName; |
| if (userMarker) { |
| userMarker.setIcon(createUserIcon(username)); |
| } |
| socket.emit('update_username', { |
| old_username: oldUsername, |
| new_username: username |
| }); |
| } |
| } |
| |
| function initMap() { |
| map = L.map('leaflet-map', { |
| center: [39.9042, 116.4074], |
| zoom: 16, |
| zoomControl: false |
| }); |
| |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { |
| maxZoom: 19 |
| }).addTo(map); |
| |
| const mapMask = document.getElementById('mapMask'); |
| mapMask.addEventListener('animationend', () => { |
| initSocket(); |
| |
| if ("geolocation" in navigator) { |
| navigator.geolocation.watchPosition( |
| updateUserPosition, |
| error => console.error("Error getting location:", error), |
| { enableHighAccuracy: true } |
| ); |
| } else { |
| alert("你的浏览器不支持地理定位功能"); |
| } |
| }); |
| } |
| |
| initMap(); |
| </script> |
| </body> |
| </html> |