Created
January 11, 2020 01:22
-
-
Save edhaase/aeb5a12149643a6be6e6dbc902ff9a3a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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]); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment