Skip to content

Instantly share code, notes, and snippets.

@edhaase
Created January 11, 2020 01:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save edhaase/aeb5a12149643a6be6e6dbc902ff9a3a to your computer and use it in GitHub Desktop.
Save edhaase/aeb5a12149643a6be6e6dbc902ff9a3a to your computer and use it in GitHub Desktop.
/**
* 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