import * as THREE from "three"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { SVGLoader } from "three/addons/loaders/SVGLoader.js"; import * as SunCalc from "suncalc"; import Color from "colorjs.io"; const numFireworks = 10; const numYays = 7; // if something fishy happens to the local storage, errors on load could render the game unplayable // use try-catch to prevent this const storageParse = (item) => { try { return JSON.parse(localStorage.getItem(item)); } catch {} }; let target = storageParse("position") || { lng: 9.142202119898826, lat: 49.97692244755174, }; let completed = storageParse("completed") || {}; const enable3d = localStorage.getItem("enable3d") != "false"; let forceTouchControl = localStorage.getItem("touchcontrol") == "true"; let gpsError = null; const touchControl = () => { return forceTouchControl || !!gpsError; }; const map = new maplibregl.Map({ container: "map", center: target, minZoom: 15, maxZoom: 20, zoom: 18, pitch: 45, minPitch: 1, antialias: true, dragPan: false, scrollZoom: { around: "center" }, touchZoomRotate: { around: "center" }, doubleClickZoom: false, attributionControl: false, bearing: 180, keyboard: false, // key leakage is part of maptiler's ecosystem *shrug* // their "fix" is to allow restricting keys to certain 'Origin' headers ("pinky promise uwu") // honestly api keys are cringe anyway style: "https://api.maptiler.com/maps/" + (enable3d ? "streets-v2" : "bright") + "/style.json?key=DOnvuOySyPyQM83lAx0a", }); // hack. otherwise, zooming/rotating won't work while moving map.stop = () => {}; // https://github.com/maplibre/maplibre-gl-js/discussions/1521 map.getCameraPosition = () => { const pitch = map.transform._pitch; const altitude = Math.cos(pitch) * map.transform.cameraToCenterDistance; const latOffset = Math.tan(pitch) * map.transform.cameraToCenterDistance; const latPosPointInPixels = map.transform.centerPoint.add( new maplibregl.Point(0, latOffset), ); const latLong = map.transform.pointLocation(latPosPointInPixels); const verticalScaleConstant = map.transform.worldSize / (2 * Math.PI * 6378137 * Math.abs(Math.cos(latLong.lat * (Math.PI / 180)))); const altitudeInMeters = altitude / verticalScaleConstant; return { lng: latLong.lng, lat: latLong.lat, altitude: altitudeInMeters, pitch: (pitch * 180) / Math.PI, }; }; const renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: map.painter.context.gl, antialias: true, }); renderer.shadowMap.enabled = true; renderer.autoClear = false; const camera = new THREE.PerspectiveCamera(); const scene = new THREE.Scene(); const gltfLoader = new GLTFLoader(); const playerScale = 5; const raycaster = new THREE.Raycaster(); const textures = new THREE.TextureLoader(); const openOverlay = (close) => { const overlay = document.body.appendChild(document.createElement("center")); overlay.style.position = "fixed"; overlay.style.top = "0px"; overlay.style.left = "0px"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.3)"; overlay.style.zIndex = "1"; if (close) overlay.addEventListener("click", (evt) => { if (evt.target != overlay) return; if (close instanceof Function) close(); document.body.removeChild(overlay); }); return overlay; }; // https://codepen.io/prisoner849/pen/abKdYgZ const setUV = (geometry) => { let pos = geometry.attributes.position; let b3 = new THREE.Box3().setFromBufferAttribute(pos); let size = new THREE.Vector3(); b3.getSize(size); let uv = []; let v3 = new THREE.Vector2(); for (let i = 0; i < pos.count; i++) { v3.fromBufferAttribute(pos, i); v3.sub(b3.min).divide(size); uv.push(v3.x, v3.y); } geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2)); }; const markers = [ { title: "Liudolf", name: "stiftskirche", pos: { lng: 9.146006727402352, lat: 49.973420131538234 }, year: 950, }, { title: "Willigis", name: "willigis_bruecke", pos: { lng: 9.141077866185924, lat: 49.97184032912233 }, year: 989, }, { title: "Hund Otto", name: "altstadt", pos: { lng: 9.143238557511694, lat: 49.973269579558774 }, year: 1122, }, { title: "Albrecht von Brandenburg", name: "schoental_ruine", pos: { lng: 9.151180069019205, lat: 49.97534736445891 }, year: 1544, }, { title: "Georg Ridinger", name: "schloss", pos: { lng: 9.142131607183956, lat: 49.9755936415456 }, year: 1605, }, { title: "Johann Schweickard von Kronberg", name: "kronberg", pos: { lng: 9.143592104550777, lat: 49.97546881166781 }, year: 1620, }, { title: "Pilger", name: "pilgerbrunnen", pos: { lng: 9.145791266971258, lat: 49.97387844558844 }, year: 1700, }, { title: "Friedrich Carl von Erthal", name: "schoental", pos: { lng: 9.153218714184447, lat: 49.97449013687282 }, year: 1775, }, { title: "Karl Theodor von Dalberg", name: "stadttheater", pos: { lng: 9.144483317758414, lat: 49.9744341620889 }, year: 1811, }, { title: "Ludwig I von Bayern", name: "pompejanum", pos: { lng: 9.136472355974632, lat: 49.97739471769839 }, year: 1840, }, ]; new SVGLoader().load("marker-model.svg", (data) => { const markerObj = new THREE.Group(); const material = new THREE.MeshBasicMaterial({ color: new THREE.Color(), side: THREE.DoubleSide, depthWrite: true, transparent: false, stencilWrite: true, stencilWriteMask: 0x80, stencilRef: 0x80, stencilFunc: THREE.AlwaysStencilFunc, stencilFail: THREE.KeepStencilOp, stencilZFail: THREE.KeepStencilOp, stencilZPass: THREE.ReplaceStencilOp, }); for (const [i, shape] of data.paths .flatMap(SVGLoader.createShapes) .entries()) { const geometry = new THREE.ShapeGeometry(shape); setUV(geometry); const mesh = new THREE.Mesh(geometry); mesh.scale.setScalar(1 / 1792); mesh.position.set(-0.5, (1536 - 118.237) / 1792, i * 0.001); mesh.rotateX(Math.PI); markerObj.add(mesh); } markerObj.scale.setScalar(50); for (const marker of markers) { marker.obj = markerObj.clone(); marker.obj.marker = marker; const texture = textures.load("markers/" + marker.name + "/icon.png"); texture.flipY = false; texture.colorSpace = THREE.SRGBColorSpace; const ch = marker.obj.children; ch[0].material = material.clone(); ch[1].material = material; const mat = (ch[2].material = material.clone()); mat.map = texture; mat.transparent = true; scene.add(marker.obj); } }); const clamp = (min, max, x) => Math.min(max, Math.max(min, x)); const rlerp = (min, max, x) => clamp(0, 1, (x - min) / (max - min)); const mapToMerc = maplibregl.MercatorCoordinate.fromLngLat; const mercToThree = ({ x, y, z }) => new THREE.Vector3(x, z, y).divideScalar( mapToMerc(map.getCenter()).meterInMercatorCoordinateUnits(), ); const threeCenter = () => mercToThree(mapToMerc(map.getCenter())); const mapToThree = (lngLat) => mercToThree(mapToMerc(lngLat, lngLat.altitude)).sub(threeCenter()); let player; const playerModels = [ { name: "Mei", scale: 3.0, }, { name: "Paul", scale: 1.5, }, { name: "Sonic", scale: 1.5, }, { name: "Naruto", scale: 3.0, animationIndex: 4, doStop: true, }, { name: "Luoli", scale: 0.03, doStop: true, }, { name: "Timo", scale: 1.0, hook: (player) => { player.scene.traverse((child) => { if ( child.name in { base: true, lamppost: true, space: true, } ) child.visible = false; }); player.scene.rotateY(Math.PI); const grp = new THREE.Group(); grp.add(player.scene); player.scene = grp; player.walk.timeScale = 2; }, }, ]; for (const m of playerModels) m.path = "models/" + m.name.toLowerCase() + "/"; const setPlayerModel = async (model) => { const gltf = await new Promise((res, rej) => { gltfLoader .setPath(model.path) .setResourcePath(model.path) .load("scene.gltf", res, null, rej); }); if (player) scene.remove(player.scene); document.getElementById("model-image").src = model.path + "preview.png"; localStorage.setItem("model", model.name); player = gltf; player.doStop = model.doStop; player.scene.traverse((child) => { if (child.isMesh) { (child.material.isMaterial ? [child.material] : child.material).forEach( (m) => { m.castShadow = true; m.stencilWrite = true; m.stencilWriteMask = 0x80; m.stencilRef = 0x80; m.stencilFunc = THREE.AlwaysStencilFunc; m.stencilFail = THREE.ReplaceStencilOp; m.stencilZFail = THREE.ReplaceStencilOp; m.stencilZPass = THREE.ReplaceStencilOp; }, ); child.castShadow = true; } }); player.scene.scale.setScalar(model.scale * playerScale); player.mixer = new THREE.AnimationMixer(player.scene); player.walk = player.mixer.clipAction( player.animations[model.animationIndex || 0], ); if (model.hook) model.hook(player); if (!player.doStop) player.walk.play(); scene.add(player.scene); }; const openContainer = (close, center) => { const overlay = openOverlay(close); const container = overlay.appendChild(document.createElement("div")); container.classList.add("ui-container"); container.style.position = "absolute"; container.style.width = "90%"; container.style.left = "5%"; if (center) { container.style.top = "50%"; container.style.transform = "translateY(-50%)"; } return [overlay, container]; }; const divOverlay = () => { const overlay = document.createElement("div"); overlay.style.position = "absolute"; overlay.style.top = "0px"; overlay.style.left = "0px"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; return overlay; }; const modelSelectionUI = (canClose) => { const [overlay, container] = openContainer(canClose, true); const perRow = innerWidth > innerHeight ? 3 : 2; const h1 = container.appendChild(document.createElement("h1")); h1.innerText = "Wähle dein Aussehen"; const table = container.appendChild(document.createElement("table")); table.style.padding = "1em"; table.style.paddingTop = ""; table.style.width = "calc(100%-2em)"; let clicked = false; let tr; for (const [i, model] of playerModels.entries()) { if (i % perRow == 0) { tr = table.appendChild(document.createElement("tr")); } const td = tr.appendChild(document.createElement("td")); td.style.position = "relative"; td.style.textAlign = "center"; td.style.backgroundColor = "#d9e9c6"; td.style.borderRadius = "10px"; td.style.cursor = "pointer"; td.addEventListener("click", () => { if (clicked) return; clicked = true; const loadOverlay = td.appendChild(divOverlay()); loadOverlay.style.borderRadius = "10px"; loadOverlay.style.textAlign = "center"; loadOverlay.style.display = "table"; const loadText = loadOverlay.appendChild(document.createElement("div")); loadText.innerText = "Lädt..."; loadText.style.fontSize = "1.5em"; loadText.style.color = "white"; loadText.style.display = "table-cell"; loadText.style.verticalAlign = "middle"; setPlayerModel(model).then(() => { overlay.remove(); }); }); td.appendChild(document.createElement("p")); const img = td.appendChild(document.createElement("img")); img.src = model.path + "preview.png"; img.style.width = "100%"; img.style.maxWidth = 100 / perRow + "%"; img.style.maxHeight = 70 / Math.ceil(playerModels.length / perRow) + "vh"; td.appendChild(document.createElement("p")).innerText = model.name; } }; { const button = document.getElementById("action-model"); button.addEventListener("click", () => { modelSelectionUI(true); }); const modelName = localStorage.getItem("model"); const model = modelName && playerModels.find((m) => m.name == modelName); if (model) setPlayerModel(model); else modelSelectionUI(); } const scrollContainer = (headline, htmlContent) => { const [overlay, container] = openContainer(true, true); container.style.height = "90%"; const close = container.appendChild(document.createElement("button")); close.style.position = "absolute"; close.style.bottom = "5px"; close.style.left = "5px"; close.style.width = "calc(100% - 10px)"; close.classList.add("ui-button"); close.classList.add("button-overlay"); close.innerText = "Schließen"; close.addEventListener("click", () => { overlay.remove(); }); const scroll = container.appendChild(document.createElement("div")); scroll.style.overflowY = "scroll"; scroll.style.maxHeight = "calc(100% - 10px - 2em)"; const fade = scroll.appendChild(document.createElement("div")); fade.classList.add("fading-edge"); const h1 = fade.appendChild(document.createElement("h1")); h1.innerText = headline; const content = fade.appendChild(document.createElement("div")); content.style.width = "90%"; content.style.textAlign = "left"; content.style.marginBottom = "calc(4em + 2em + 5px)"; content.style.position = "relative"; content.innerHTML = htmlContent; return [overlay, close]; }; document.getElementById("action-settings").addEventListener("click", () => { const [overlay, buttonClose] = scrollContainer( "Einstellungen", `
Wenn du das Spiel von vorne beginnen möchtest, kannst du hier den Fortschritt zurücksetzen. Alle Marker sind danach wieder als unbekannt markiert.
Wenn das Spiel ein Problem oder Fehler hat, kann es unter Umständen helfen, es neu zu laden. Dazu kannst du die Seite neu laden oder diesen Knopf verwenden. Dein Fortschritt bleibt dabei erhalten.
Im 3D-Modus werden Gebäude dreidimensional auf der Karte angezeigt. Auf leistungsschwachen Geräten kann das zu Leistungsproblemen führen. Nach Änderung dieser Einstellung wird das Spiel neu geladen.
Das Spiel kann entweder durch deinen Standort oder durch Berühren der Karte gesteuert werden. Falls dein Gerät keine Standortinformationen unterstützt, wird die Berührsteuerung automatisch eingeschaltet.
`, ); const boxEnable3d = document.getElementById("checkbox-enable3d"); const boxTouchControl = document.getElementById("checkbox-touchcontrol"); boxEnable3d.checked = enable3d; boxTouchControl.checked = touchControl(); boxTouchControl.disabled = !!gpsError; const updateCloseButton = () => { if (boxEnable3d.checked != enable3d) buttonClose.innerText = "Speichern und neu laden"; else if (boxTouchControl.checked != touchControl()) buttonClose.innerText = "Speichern und schließen"; else buttonClose.innerText = "Schließen"; }; boxEnable3d.addEventListener("input", updateCloseButton); boxTouchControl.addEventListener("input", updateCloseButton); buttonClose.addEventListener("click", () => { localStorage.setItem("enable3d", boxEnable3d.checked.toString()); if (!gpsError) localStorage.setItem( "touchcontrol", (forceTouchControl = boxTouchControl.checked).toString(), ); if (boxEnable3d.checked != enable3d) location.reload(); }); document.getElementById("reset-progress").addEventListener("click", () => { if (confirm("Möchtest du deinen Fortschritt zurücksetzen?")) { localStorage.setItem("completed", JSON.stringify((completed = {}))); alert("Fortschritt zurückgesetzt."); overlay.remove(); } }); document.getElementById("reload-game").addEventListener("click", () => { location.reload(); }); }); import licenseFile from "./LICENSE?url"; document.getElementById("action-info").addEventListener("click", () => { const modelLicenses = playerModels .map( ({ name, path }) => `Du kannst bei diesem Spiel Figuren der Stadtgeschichte Aschaffenburgs entdecken, ihnen zuhören und sie dann in eine Zeitleiste einordnen.
Wähle eine Spielerfigur und zoome mit zwei Fingern den Stadtplan und suche nach den hellblauen Icons. Dann kannst du zu einem Icon hinlaufen und es anklicken.
Alternativ kannst du auch unter "Einstellungen" die Berührfunktion wählen. In diesem Fall musst du nur auf die Stelle im Stadtplan klicken, wo die Figur hinlaufen soll.
Wenn du das Icon angeklickt hast, erscheint ein Bild der Figur und trägt dir etwas über sich und das Gebäude, vor dem du stehst vor. Pass gut auf, und merke dir die Jahreszahl. Wenn die Figur fertig gesprochen hat, erscheint eine Zeitleiste. Du kannst in der Zeitleiste nach oben und unten scrollen. Ordne deine Figur dem passenden Feld zu.
Wenn du unsicher bist, kannst du dir den Text noch einmal anhören.
Wenn du falsch antwortest, kannst du es noch einmal probieren.
Wenn du richtig geantwortet hast, bekommst du ein tolles Feuerwerk zur Belohnung.
Über "Schließen" kannst du die Zeitleiste wieder schließen und weiter spielen.
Ziel des Spiel ist es alle 10 Figuren einzuordnen.
Idee und Leitung: Ruth Pabst
Inhalte: Schülerinnen und Schüler der Klasse 4a der Christian-Schad-Schule: Alexia, Andreo, Inga, Leonard, Lukas, Mara, Noah, Polina, Raphael und andere
Programmierung und Design: Charlotte Pabst (Pseudonym: "Lizzy Fleckenstein")
Der Quelltext des Programms steht auf GitHub zur Verfügung.
Dieses Programm ist Freie Software: Sie können es unter den Bedingungen der GNU General Public License, wie von der Free Software Foundation, Version 3 der Lizenz oder (nach Ihrer Wahl) jeder neueren veröffentlichten Version, weiter verteilen und/oder modifizieren. Dieses Programm wird in der Hoffnung, dass es nützlich sein wird, jedoch OHNE JEDE GEWÄHRLEISTUNG, bereitgestellt; sogar ohne die implizite Gewährleistung der MARKTFÄHIGKEIT oder EIGNUNG FÜR EINEN BESTIMMTEN ZWECK. Siehe die GNU General Public License für weitere Einzelheiten. Sie sollten eine Kopie der GNU General Public License zusammen mit diesem Programm erhalten haben. Wenn nicht, siehe https://www.gnu.org/licenses/
Alle Inhalte in den 'markers', 'yay' und 'fireworks' Ordnern wurden von Schülerinnen und Schülern bzw. Ruth Pabst erstellt und werden unter CC BY-SA 4.0 zur Verfügung gestellt.