/** * MiniMap.js - MiniMap for the game Adventure.Land * * Features: * Event emitter * Drag to reposition * Numpad plus/minus to zoom * Toggle visibility to show or hide * Left click to interact with markers, right click to smart move * Easily extensible * * Tips: * Toggle visibility: minimap.visible = !minimap.visible * Toggle minimized: minimap.minimized = !minimap.minimized * * Instantiation: * * if (game.graphics) { * const minimap = new MiniMap(); * parent.drawings.push(minimap); * parent.stage.addChild(minimap); * } * * Cleanup: * None. Cleanup is handled by the game's clear_drawings call. * * @todo mouse roll zoom via scale? */ 'use strict'; const GUI_WINDOW_BORDER_COLOR = 0x47474F; const GUI_WINDOW_INTERIOR_COLOR = 0x40420; const GUI_WINDOW_TOOLBAR_HEIGHT = get('GUI_WINDOW_TOOLBAR_HEIGHT') || 25; const GUI_WINDOW_TOOLBAR_BACKGROUND_COLOR = get('GUI_WINDOW_TOOLBAR_BACKGROUND_COLOR') || 0x00; const GUI_WINDOW_TOOLTIP_FONT_SIZE = 11; const GUI_WINDOW_BORDER_SIZE = get('MINIMAP_BORDER') || 6; // The radius of the outer line around the minimap const GUI_WINDOW_DEFAULT_WIDTH = 240; const GUI_WINDOW_DEFAULT_HEIGHT = 240; const GUI_WINDOW_ORIGIN = get('GUI_WINDOW_ORIGIN') || { x: 175 + 50, y: 175 }; // Where the map is drawn on the screen const MINIMAP_WALL_COLOR = 0x47474F; const MINIMAP_NPC_COLOR = 0x2341DB; const MINIMAP_DOOR_COLOR = 0xAAAAAA; const MINIMAP_SCALE = get('MINIMAP_SCALE') || 6; const MINIMAP_TRACKER_FREQUENCY = get('MINIMAP_TRACKER_FREQUENCY') || 500; const MINIMAP_MAP_TRACKER_UPDATE_FREQUENCY = get('MINIMAP_ENTITY_FREQUENCY') || 100; const MINIMAP_ENTITY_SIZE = get('MINIMAP_ENTITY_SIZE') || 12; const MINIMAP_NPC_SIZE = get('MINIMAP_NPC_SIZE') || 15; function clamp(low, value, high) { return Math.min(Math.max(low, value), high); } function truncate(str, len = 24) { if (!str) return null; let sub = str.slice(0, len - 3); if (str.length > sub.length) sub += '...'; return sub; } /** * PIXI.js window with dragging */ class GuiWindow extends PIXI.Container { constructor(id, opts) { super(); this.id = id; // Used for persistence this.restorePosition(); } restorePosition() { const pos = get(`GUI_WINDOW_${this.id}_POS`) || {}; const { x = GUI_WINDOW_ORIGIN.x, y = GUI_WINDOW_ORIGIN.y } = pos; const { width = GUI_WINDOW_DEFAULT_WIDTH, height = GUI_WINDOW_DEFAULT_HEIGHT } = pos; this.position.x = x; this.position.y = y; this.width = width; this.height = height; this.createViewport(); } persist() { set(`GUI_WINDOW_${this.id}_POS`, { x: this.position.x, y: this.position.y, width: this.width, height: this.height }); } get minimized() { return !this.backdrop.visible; } set minimized(b) { this.backdrop.visible = !b; } createViewport() { const pos = get(`GUI_WINDOW_${this.id}_POS`) || {}; const { x = GUI_WINDOW_ORIGIN.x, y = GUI_WINDOW_ORIGIN.y } = pos; const { width = GUI_WINDOW_DEFAULT_WIDTH, height = GUI_WINDOW_DEFAULT_HEIGHT } = pos; this.position.x = x; this.position.y = y; // Create main backdrop this.backdrop = this.addChild(new PIXI.Graphics()); this.backdrop.lineStyle(GUI_WINDOW_BORDER_SIZE, GUI_WINDOW_BORDER_COLOR); this.backdrop.beginFill(GUI_WINDOW_INTERIOR_COLOR); this.backdrop.drawRect(0, 0, width - GUI_WINDOW_BORDER_SIZE, height - GUI_WINDOW_TOOLBAR_HEIGHT - GUI_WINDOW_BORDER_SIZE); this.backdrop.position.y = GUI_WINDOW_TOOLBAR_HEIGHT; this.backdrop.endFill(); // Create mask (Apparently needs to be added to container to work properly) const mask = this.backdrop.addChild(new PIXI.Graphics()); mask.lineStyle(0, 0x000000); // Don't draw the border lines here, since we're drawing a mask. mask.beginFill(GUI_WINDOW_INTERIOR_COLOR); mask.drawRect(GUI_WINDOW_BORDER_SIZE / 2, GUI_WINDOW_BORDER_SIZE / 2, width - GUI_WINDOW_BORDER_SIZE * 2, height - GUI_WINDOW_TOOLBAR_HEIGHT - GUI_WINDOW_BORDER_SIZE * 2); mask.endFill(); this.windowMask = mask; // Create toolbar and touch points last const t = this.addChild(new PIXI.Graphics()); t.lineStyle(GUI_WINDOW_BORDER_SIZE, GUI_WINDOW_BORDER_COLOR); t.beginFill(GUI_WINDOW_TOOLBAR_BACKGROUND_COLOR); t.drawRect(0, 0, this.width - GUI_WINDOW_BORDER_SIZE, GUI_WINDOW_TOOLBAR_HEIGHT); t.interactive = true; this.toolbar = t; t .on('pointerdown', this.onDragStart.bind(this)) .on('pointerup', this.onDragEnd.bind(this)) .on('pointerupoutside', this.onDragEnd.bind(this)) .on('pointermove', this.onDragMove.bind(this)); // Title bar this.title = t.addChild(new PIXI.Text(null, { fontSize: 20, fill: 0xFFFFFF })); return this.backdrop; } onDragStart(e) { if (this.dragging) return; this.dragging = true; this.alpha *= 0.5; const { x, y } = e.data.getLocalPosition(this); this.pivot.set(x, y); this.position.set(e.data.global.x, e.data.global.y); } onDragEnd() { if (!this.dragging) return; this.dragging = false; this.alpha /= 0.5; set(`GUI_WINDOW_${this.id}_POS`, { x: this.x - this.pivot.x, y: this.y - this.pivot.y }); } onDragMove(e) { if (!this.dragging) return; const { x, y } = e.data.getLocalPosition(this.parent); this.position.set(x, y); } } /** * Mark a point of interest in the game using in game cooridates */ export class MapMarker extends PIXI.Graphics { constructor(x = 0, y = 0, color = 0xFFFFFF, radius = MINIMAP_ENTITY_SIZE) { super(); this.zIndex = 2000; this.radius = radius; this.color = color; this.position.x = x; this.position.y = y; this.interactive = true; this.redraw(); this .on('pointerover', this.onMouseOver, this) .on('pointerout', this.onMouseOut, this) } getMap() { if (!this.map) { for (let item = this.parent; item; item = item.parent) { if (item instanceof MiniMap) { this.map = item; break; } } } return this.map; } onMouseOver(e) { this.mouse_in = true; console.log(`On entity mouse over`, e); const map = this.getMap(); map.setTooltip(this.tag || this.name); } onMouseOut(e) { this.mouse_in = false; console.log(`On entity mouse out`, e); const map = this.getMap(); map.setTooltip(null); } redraw() { this.clear(); this.lineStyle(this.radius, this.color); this.beginFill(this.color); this.drawCircle(0, 0, this.radius); this.endFill(); } } /** * Map marker that adjusts to track position * * @todo move entity specific stuff out. MapTracker just interval tracks */ export class MapTracker extends MapMarker { constructor(color, size, entity, freq = MINIMAP_MAP_TRACKER_UPDATE_FREQUENCY) { super(0, 0, color, size); this.eid = entity.id || entity; this.visible = false; this.freq = freq; this.lastUpdate = 0; this.timer = setInterval(() => this.update(), freq); } destroy() { clearInterval(this.timer); super.destroy({ children: true }); } } export class PlayerTracker extends MapMarker { constructor() { super(0, 0, 0xFFFFFF, MINIMAP_ENTITY_SIZE); } updateTransform() { this.position.x = character.real_x; this.position.y = character.real_y; return super.updateTransform(); } } export class EntityTracker extends MapTracker { constructor(entity, freq) { super(0x00000, MINIMAP_ENTITY_SIZE, entity, freq); this.on('pointerup', this.onClick, this) } onClick(e) { const entity = parent.entities[this.eid]; if (entity.mtype && !entity.dead && entity.visible !== false) { change_target(entity, true); game_log(`Attacking ${entity.name}`); } } updateTransform() { const entity = parent.entities[this.eid]; if (!entity || entity.dead) return; // We can't destroy it here or we break the renderer this.position.x = entity.real_x; this.position.y = entity.real_y; return super.updateTransform(); } update() { const entity = parent.entities[this.eid]; if (!entity || entity.dead) return this.destroy({ children: true }); this.visible = true; if (entity.type === 'character') { if (parent.party_list.includes(entity.id)) { this.color = 0x1BD545; } else if (entity.npc == null) { this.color = 0xDCE20F; } else { this.color = 0x2341DB; } } else { if (entity.mtype != null && (parent.G.monsters[entity.mtype].respawn == -1 || parent.G.monsters[entity.mtype].respawn > 60 * 2)) { this.color = 0x40420; } if (entity.target) this.color = 0xff9900; else this.color = 0xEE190E; } this.redraw(); } } export class MapTracking extends PIXI.Container { constructor(freq = MINIMAP_TRACKER_FREQUENCY) { super(); this.zIndex = 1005; this.tracking = new WeakMap(); this.timer = setInterval(() => this.update(), freq); this.update(); } destroy() { clearInterval(this.timer); super.destroy({ children: true }); } } class EntityTracking extends MapTracking { update() { for (const entity of Object.values(parent.entities)) { if (this.tracking.has(entity) || entity.dead) continue; const tracker = this.createTracker(entity); this.tracking.set(entity, tracker); this.addChild(tracker); } } createTracker(entity) { /*if (entity.type === 'monster') { const sprite = parent.new_sprite(entity.mtype, 'skin'); sprite.position.set(entity.real_x, entity.real_y); entity.scale = 2; return sprite; } */ const tracker = new EntityTracker(entity, MINIMAP_MAP_TRACKER_UPDATE_FREQUENCY); tracker.tag = entity.name; return tracker; } } class NpcTracking extends MapTracking { update() { const npcs = G.maps[character.map].npcs; for (const npc of Object.values(npcs)) { if (npc.loop || npc.manual || !npc.position) continue; const [x, y] = npc.position; if (x === 0 && y === 0) continue; if (this.tracking.has(npc)) continue; const marker = new MapMarker(x, y, MINIMAP_NPC_COLOR, MINIMAP_NPC_SIZE); marker.tag = npc.name || G.npcs[npc.id].name || npc.id; this.tracking.set(npc, marker); this.addChild(marker); } } } // Exists to encapsulates zoom and scale. class ZoomLayer extends PIXI.Container { setZoom(fr = MINIMAP_SCALE) { if (fr <= 0 || fr > 10) return; this.fr = fr; this.scale.x = 1 / this.fr; this.scale.y = 1 / this.fr; console.debug(`Setting MINIMAP_SCALE to ${this.fr}`); set('MINIMAP_SCALE', this.fr); } incZoom() { this.setZoom(this.fr - 1); } decZoom() { this.setZoom(this.fr + 1); } } /** * The actual minimap */ export default class MiniMap extends GuiWindow { constructor() { super(`MINI_MAP`); this.zIndex = 999; this.map_listener = game.on('new_map', (e) => this.emit('new_map', e)); // Event forwarding this.on('new_map', (e) => this.onMapChange(e)); // Because event listeners are written kind of poorly this.onKeyUpBound = this.onKeyUp.bind(this); parent.addEventListener('keyup', this.onKeyUpBound); this.onMapChange(null); } /** * Cleanup */ destroy(opts) { try { game.remove(this.map_listener); parent.removeEventListener('keyup', this.onKeyUpBound); } finally { opts.chldren = true; super.destroy(opts); } } updateTransform() { this.recenter(); return super.updateTransform(); } recenter(force = false) { if (!this.dynamics) return; if (!is_moving(character) && !force) // @todo only recenter if moving, otherwise allow scrolling return; this.dynamics.pivot.x = character.real_x; // - this.width / 2; this.dynamics.pivot.y = character.real_y; // - this.height / 2; // this.zoomlayer.pivot.x = 0; // may be useful for map scrolling // this.zoomlayer.pivot.y = 0; const { min_x, min_y, max_x, max_y } = G.maps[character.map].data; this.dynamics.pivot.x = clamp(min_x, this.dynamics.pivot.x, max_x); this.dynamics.pivot.y = clamp(min_y, this.dynamics.pivot.y, max_y); } /** * Event handling */ onKeyUp(e) { if (e.code === 'NumpadAdd') this.zoomlayer.incZoom(); else if (e.code === 'NumpadSubtract') this.zoomlayer.decZoom(); } onMapClick(e) { const { x, y } = e.data.getLocalPosition(this.dynamics); console.debug(`onMapClick`, x, y, e); if (is_moving(character)) stop(); smart_move({ map: character.map, x, y }); } onMapChange(e) { console.debug(`onMapChange`, e); this.setTitle(G.maps[character.map].name); if (this.dynamics) { this.dynamics.destroy({ children: true }); this.dynamics = null; this.interactables = null; } this.createDynamics(); this.zoomlayer.addChildAt(this.dynamics, 0); this.dynamics.addChild(new PlayerTracker()); this.recenter(true); } addSimpleMapMarker(name, pos, color = 0xAAAAAA) { const marker = this.interactables.addChild(new MapMarker(pos.x, pos.y, color)); marker.tag = name; } addMapMarker(marker) { this.dynamics.addChild(marker); } setTitle(str) { this.title.text = truncate(str); this.title.position.x = (this.title.parent.width / 2) - (this.title.width / 2); } setTooltip(str) { this.tooltip.text = truncate(str); } createDynamics() { this.dynamics = new PIXI.Container(); const map = this.dynamics.addChild(new PIXI.Graphics()); const { min_x, min_y, max_x, max_y } = G.maps[character.map].data; const margin = 1000; // Margin is useful so we have a clickable surface for drag map.interactive = true; map.beginFill(GUI_WINDOW_INTERIOR_COLOR, 1.0); // Apparently this surface is required to register map clicks map.drawRect(min_x - margin, min_y - margin, max_x - min_x + (margin * 2), max_y - min_y + (margin * 2)); this.drawWalls(map); map.cacheAsBitmap = true; map.on('mousedown', this.onMapDragStart, this); // Left click drag so we don't accidentally move while trying to click an entity map.on('mouseup', this.onMapDragEnd, this); map.on('mouseupoutside', this.onMapDragEnd, this); map.on('mouseout', this.onMapDragEnd, this); map.on('mousemove', this.onMapDragMove, this); map.on('rightup', this.onMapClick, this); this.interactables = this.dynamics.addChild(new PIXI.Container); // Dynamic objects container this.interactables.addChild(new EntityTracking()); this.interactables.addChild(new NpcTracking()); for (const door of G.maps[character.map].doors) { const [x, y, , , dest] = door; this.addSimpleMapMarker(G.maps[dest].name, { x, y }, MINIMAP_DOOR_COLOR); } } onMapDragStart(e) { if (is_moving(character) || this.draggingMap) return; this.draggingMap = true; this.dynamics.alpha *= 0.5; this.interactables.interactiveChildren = false; /* const { x, y } = e.data.getLocalPosition(this.dynamics); this.dynamics.pivot.set(x, y); */ const viewPosition = e.data.getLocalPosition(this.backdrop); const { x, y } = e.data.getLocalPosition(this.dynamics); //this.zoomlayer.pivot // this.dynamics.position.set(e.data.global.x, e.data.global.y); } onMapDragEnd() { if (!this.draggingMap) return; this.dynamics.alpha /= 0.5; this.draggingMap = false; this.interactables.interactiveChildren = true; } onMapDragMove(e) { if (!this.dynamics.dragging) return; // Adjust for container coordinates? // const newPosition = e.data.getLocalPosition(this.dynamics.parent); const viewPosition = e.data.getLocalPosition(this.backdrop); const newPosition = e.data.getLocalPosition(this.dynamics); const { x, y } = newPosition; console.log('Drag event', viewPosition, newPosition, e); this.zoomlayer.pivot.x = (this.backdrop.width / 2) - viewPosition.x; this.zoomlayer.pivot.y = (this.backdrop.height / 2) - viewPosition.y; // this.dynamics.pivot.x = newPosition.x; // this.dynamics.pivot.y = newPosition.y; // this.dynamics.pivot.x = newPosition.x / this.zoomlayer.fr * 2; // this.dynamics.pivot.y = newPosition.y / this.zoomlayer.fr * 2; const { min_x, min_y, max_x, max_y } = G.maps[character.map].data; this.dynamics.pivot.x = clamp(min_x, this.dynamics.pivot.x, max_x); this.dynamics.pivot.y = clamp(min_y, this.dynamics.pivot.y, max_y); } /** * Create elements that don't need to redraw */ createViewport() { super.createViewport(); // Create camera / scaling leayer this.zoomlayer = this.backdrop.addChild(new ZoomLayer()); this.zoomlayer.mask = this.windowMask; this.zoomlayer.position.x = this.backdrop.width / 2; this.zoomlayer.position.y = this.backdrop.height / 2; this.zoomlayer.setZoom(MINIMAP_SCALE); // This works, until we start scrolling // this.zoomlayer.addChild(new MapMarker(0, 0, 0xFFFFFF)); this.tooltip = this.backdrop.addChild(new PIXI.Text(null, { fontSize: 16, fill: 0xFFFFFF })); this.tooltip.position.y = this.backdrop.height - this.tooltip.height - GUI_WINDOW_BORDER_SIZE - 5; this.tooltip.position.x = GUI_WINDOW_BORDER_SIZE + 1; } drawLine(e, [x1, y1], [x2, y2]) { e.moveTo(x1, y1); e.lineTo(x2, y2); e.endFill(); } drawWalls(g) { const size = get('MINIMAP_LINE_SIZE') || 11; g.lineStyle(size, MINIMAP_WALL_COLOR); const map_data = parent.G.maps[character.map].data; for (const id in map_data.x_lines) { const line = map_data.x_lines[id]; const x1 = line[0]; const y1 = line[1]; const x2 = line[0]; const y2 = line[2]; this.drawLine(g, [x1, y1], [x2, y2]); } for (const id in map_data.y_lines) { const line = map_data.y_lines[id]; const x1 = line[1]; const y1 = line[0]; const x2 = line[2]; const y2 = line[0]; this.drawLine(g, [x1, y1], [x2, y2]); } } }