1 import { io } from 'socket.io-client';
2 import $ from 'jquery';
3 import {expToLevel, maxHp, Player, StatDef, StatDisplay} from '../shared/player';
4 import { authToken, http } from './http';
5 import { CustomEventManager } from './events';
6 import {Fight, FightTrigger, MonsterForFight, MonsterForList} from '../shared/monsters';
7 import {FightRound} from '../shared/fight';
8 import { City, Location, LocationType, Path } from '../shared/map'
9 import { v4 as uuid } from 'uuid';
10 import {EquipmentSlot, ShopEquipment} from '../shared/inventory';
11 import { capitalize, each } from 'lodash';
12 import {EquippedItemDetails} from '../shared/equipped';
13 import { Skill, Skills } from '../shared/skills';
14 import { configureChat } from './chat';
15 import { SocketEvent } from './socket-event.client';
16 import * as EventList from '../events/client';
17 import { TimeManager } from '../shared/time';
18 import { TravelDTO } from '../events/travel/shared';
19 import { Item, PlayerItem, ShopItem } from '../shared/items';
20 import { Modal } from './modal';
23 const time = new TimeManager();
24 const cache = new Map<string, any>();
25 const events = new CustomEventManager();
28 'x-authtoken': authToken()
32 configureChat(socket);
34 function setTimeGradient() {
35 const gradientName = time.gradientName();
36 const $body = $('body');
37 $body.removeClass(Array.from($body[0].classList)).addClass(gradientName);
38 $body.addClass(time.getTimePeriod());
41 if(time.get24Hour() >= 5 && time.get24Hour() < 9) {
44 else if(time.get24Hour() >= 9 && time.get24Hour() < 17) {
47 else if(time.get24Hour() >= 17 && time.get24Hour() < 20) {
54 $('#time-of-day').html(`<img src="/assets/img/icons/time-of-day/${icon}.png"> ${time.getHour()}${time.getAmPm()}`);
58 setInterval(setTimeGradient, 60 * 1000);
60 function icon(name: string): string {
61 return `<img src="/assets/img/${name}.png" class="icon">`;
65 function generateProgressBar(current: number, max: number, color: string, displayPercent: boolean = true): string {
68 percent = Math.floor((current / max) * 100);
70 const display = `${displayPercent? `${percent}% - `: ''}`;
71 return `<div class="progress-bar" style="background: linear-gradient(to right, ${color}, ${color} ${percent}%, transparent ${percent}%, transparent)" title="${display}${current}/${max}">${display}${current}/${max}</div>`;
74 function progressBar(current: number, max: number, el: string, color: string) {
77 percent = Math.floor((current / max) * 100);
81 `linear-gradient(to right, ${color}, ${color} ${percent}%, transparent ${percent}%, transparent)`
82 ).attr('title', `${percent}% - ${current}/${max}`).html(`${current}/${max} - ${percent}%`);
85 function updatePlayer() {
86 const player: Player = cache.get('player');
88 $('#username').html(`${player.username}, level ${player.level} ${player.profession}`);
92 maxHp(player.constitution, player.level),
99 expToLevel(player.level + 1),
104 ['strength', 'constitution', 'dexterity', 'intelligence','hp','exp'].forEach(s => {
105 $(`.${s}`).html(player[s]);
108 $('.maxHp').html(maxHp(player.constitution, player.level).toString());
109 $('.expToLevel').html(expToLevel(player.level + 1).toString());
110 $('.gold').html(player.gold.toLocaleString());
112 if(player.account_type === 'session') {
113 $('#signup-prompt').html(`<p>Hey there! It looks like you're using a SESSION account. This allows you to play with away, but the account is tied to your current device, and will be lost if you ever clear your cookies.</p><p>
115 <div class="form-group">
116 <label>Username:</label>
117 <input type="text" name="username">
119 <div class="form-group">
120 <label>Password:</label>
121 <input type="password" name="password">
123 <button type="submit" class="success">Create your Account</button>
124 <button type="button" id="login">Login To Existing Account</button>
125 </form></p>`).removeClass('hidden');
128 $('#extra-inventory-info').html(renderStatDetails());
131 socket.on('connect', () => {
132 console.log(`Connected: ${socket.id}`);
135 socket.on('ready', bootstrap);
137 socket.on('server-stats', (data: {onlinePlayers: number}) => {
138 $('#server-stats').html(`${data.onlinePlayers} players online`);
141 each(EventList, event => {
142 console.log(`Binding Event ${event.eventName}`);
143 if(event instanceof SocketEvent) {
144 socket.on(event.eventName, event.handler.bind(null, {
151 console.log('Skipped binding', event);
155 socket.on('calc:ap', (data: {ap: Record<EquipmentSlot, { currentAp: number, maxAp: number}>}) => {
160 ${generateProgressBar(ap.HEAD?.currentAp || 0, ap.HEAD?.maxAp || 0, '#7be67b')}
164 ${generateProgressBar(ap.ARMS?.currentAp || 0, ap.ARMS?.maxAp || 0, '#7be67b')}
168 ${generateProgressBar(ap.CHEST?.currentAp || 0, ap.CHEST?.maxAp || 0, '#7be67b')}
172 ${generateProgressBar(ap.LEGS?.currentAp || 0, ap.LEGS?.maxAp || 0, '#7be67b')}
175 $('#ap-bar').html(html);
178 events.on('tab:skills', () => {
179 $('#skills').html('');
180 socket.emit('skills');
183 socket.on('skills', (data: {skills: Skill[]}) => {
184 let html = `<table id="skill-list">
185 ${data.skills.map((skill: Skill) => {
186 const definition = Skills.get(skill.id);
187 const percent = skill.exp / definition.expToLevel(skill.level + 1);
190 <td class="skill-level">${skill.level.toLocaleString()}</td>
191 <td class="skill-description" title="Total Exp: ${skill.exp.toLocaleString()}/${definition.expToLevel(skill.level + 1).toLocaleString()}">
192 <span class="skill-exp">${(percent * 100).toPrecision(2)}% to next level</span>
193 <b>${definition.display}</b>
194 <p>${definition.description}</p>
201 $('#skills').html(html);
204 function Alert(text: string, type: string = 'error') {
206 $('#alerts').append(`<div class="alert ${type}" id="alert-${id}">${text}</div>`);
209 $(`#alert-${id}`).remove();
213 events.on('alert', (data: {type: string, text: string}) => {
214 Alert(data.text, data.type);
217 socket.on('alert', data => {
218 events.emit('alert', [data]);
221 socket.on('init', (data: {version: string}) => {
222 $('#version').html(`v${data.version}`);
225 socket.on('authToken', (authToken: string) => {
226 console.log(`recv auth token ${authToken}`);
227 localStorage.setItem('authToken', authToken);
231 socket.on('player', (player: Player) => {
232 cache.set('player', player);
236 async function fetchState() {
237 const res = await http.get('/state');
238 cache.set('state', res);
241 $('nav a').on('click', async e => {
245 const tabsDisabledInFight = [
251 const state = cache.get('state');
253 const $tabContainer = $(`#${$(e.target).data('container')}`);
254 let tabSection = $(e.target).data('section');
256 if(tabsDisabledInFight.includes(tabSection) && state?.fight !== null) {
257 Alert('You are currently in a fight', 'error');
258 // we want to force users over to the explore tab
259 // if they are currently in a fight and trying to
260 // access a disabled section
261 tabSection = 'explore';
264 $tabContainer.find('.tab').removeClass('active');
266 $(`#${tabSection}`).addClass('active');
268 if(e.target.innerHTML !== 'Settings') {
269 $(e.target).closest('nav').find('a').removeClass('active');
270 $(`nav a[data-section=${tabSection}]`).addClass('active');
273 events.emit(`tab:${tabSection}`);
276 events.on('tab:inventory', () => {
277 socket.emit('inventory');
280 function renderEquipmentPlacementGrid(items: EquippedItemDetails[]) {
281 const placeholder = 'https://via.placeholder.com/64x64';
283 const map: Record<EquipmentSlot, EquippedItemDetails> = items.filter(item => item.is_equipped).reduce((acc, item) => {
284 acc[item.equipment_slot] = item;
289 <table id="character-equipment-placement">
293 <td style="background-image: url('${placeholder}');" title="${map.HEAD ? map.HEAD.name : 'Empty'}">
294 ${map.HEAD ? map.HEAD.name : 'HEAD'}
296 <td style="background-image: url('${placeholder}');" title="${map.ARMS ? map.ARMS.name : 'Empty'}">
297 ${map.ARMS ? map.ARMS.name : 'ARMS'}
301 <td style="background-image: url('${placeholder}');" title="${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
302 ${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'L_HAND')}
304 <td style="background-image: url('${placeholder}');" title="${map.CHEST ? map.CHEST.name : ''}">
305 ${map.CHEST ? map.CHEST.name : 'CHEST'}
307 <td style="background-image: url('${placeholder}');" title="${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
308 ${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'R_HAND')}
314 <td style="background-image: url('${placeholder}');" title="${map.LEGS ? map.LEGS.name : ''}">
315 ${map.LEGS ? map.LEGS.name : 'LEGS'}
325 function statPointIncreaser(stat: StatDisplay) {
326 return `<button class="increase-stat emit-event" data-event="spend-stat-point" data-args="${stat.id}">+</button>`;
328 function renderStatDetails() {
329 const player: Player = cache.get('player');
330 let html = '<table id="stat-breakdown">';
332 StatDef.forEach(stat => {
334 <th>${stat.display}</th>
335 <td class="${stat.id}">
337 ${player.stat_points ? statPointIncreaser(stat) : ''}
342 html += `<tr><th>Stat Points</th><td class="stat_points">${player.stat_points}</td></tr>`;
349 $('body').on('click', 'nav.filter', e => {
353 const $target = $(e.target);
354 const filter = $target.data('filter');
356 $('.filter-result').removeClass('active').addClass('hidden');
357 $(`#filter_${filter}`).addClass('active').removeClass('hidden');
359 $target.closest('nav').find('a').removeClass('active');
360 $target.addClass('active');
362 const cacheKey = `active-${$(e.target).closest('nav').attr('id')}`;
364 cache.set(cacheKey, filter);
367 $('body').on('click', '.close-modal', e => {
368 const $el = $(e.target).closest('dialog').get(0) as HTMLDialogElement;
373 $('body').on('click', '.trigger-modal', (e) => {
374 const $el = $(e.target).closest('.trigger-modal');
375 const modal = new Modal();
377 modal.on('ready', async (m: Modal) => {
378 const res: {description: string} = await http.get($el.data('endpoint'));
380 m.setBody(res.description);
389 function renderInventoryItems(items: PlayerItem[]): string {
390 return items.map(item => {
392 <div class="player-item trigger-modal" data-endpoint="/modal/items/${item.item_id}">
393 <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}">
394 <span class="amount">${item.amount.toLocaleString()}</span>
400 function renderInventorySection(inventory: EquippedItemDetails[]): string {
401 return inventory.map(item => {
402 return renderInventoryItem(item, item => {
403 if(item.is_equipped) {
404 return `<button type="button" class="unequip-item error" data-id="${item.item_id}">Unequip</button>`;
407 if(item.equipment_slot === 'ANY_HAND') {
408 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="LEFT_HAND">Equip L</button>
409 <button type="button" class="equip-item" data-id="${item.item_id}" data-slot="RIGHT_HAND">Equip R</button>`;
411 else if(item.equipment_slot === 'LEFT_HAND') {
412 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Left</button>`;
414 else if(item.equipment_slot === 'RIGHT_HAND') {
415 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Right</button>`;
417 else if(item.equipment_slot === 'TWO_HANDED') {
418 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
421 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
428 events.on('tab:profile', () => {
429 const html = `<div id="extra-inventory-info">
430 ${renderStatDetails()}
433 $('#profile').html(html);
436 socket.on('inventory', (data: {inventory: EquippedItemDetails[], items: PlayerItem[]}) => {
437 const player: Player = cache.get('player');
438 const activeSection = cache.get('active-inventory-section') || 'ARMOUR';
439 // split the inventory into sections!
440 const sectionedInventory: {ARMOUR: EquippedItemDetails[], WEAPON: EquippedItemDetails[], SPELL: EquippedItemDetails[], ITEMS: PlayerItem[]} = {
447 data.inventory.forEach(item => {
448 sectionedInventory[item.type].push(item);
450 data.items.forEach(item => {
451 sectionedInventory.ITEMS.push(item);
455 <div id="inventory-page">
456 <div id="character-summary">
457 ${renderEquipmentPlacementGrid(data.inventory)}
459 <div id="inventory-section" class="filter-container">
460 <nav class="filter" id="inventory-section">
461 ${Object.keys(sectionedInventory).map(type => {
462 return `<a href="#" data-filter="${type}" class="${activeSection === type ? 'active': ''}">${capitalize(type)}</a>`;
465 <div class="inventory-listing listing">
466 ${Object.keys(sectionedInventory).map(type => {
467 return `<div class="filter-result ${activeSection === type ? 'active' : 'hidden'} inventory-${type}" id="filter_${type}">
468 ${type === 'ITEMS' ? renderInventoryItems(sectionedInventory[type]) : renderInventorySection(sectionedInventory[type])}
476 $('#inventory').html(html);
479 $('body').on('click', '.equip-item', e => {
483 const $target = $(e.target);
484 const id = $target.data('id');
485 const slot = $target.data('slot');
487 socket.emit('equip', {id, slot});
490 $('body').on('click', '.unequip-item', e => {
494 socket.emit('unequip', { id: $(e.target).data('id') });
497 function setMapBackground(city_id: number) {
499 'background-image': `linear-gradient(to left top, rgba(255,255,255,0) 0%,rgb(255,255,255) 100%), linear-gradient(to left, rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 100%), url("/assets/img/map/${city_id}.jpeg")`
503 events.on('renderMap', async function renderMap(data: { city: City, locations: Location[], paths: Path[]}) {
504 if(!data && cache.has('currentMapHTML')) {
505 $('#map').html(cache.get('currentMapHTML'));
510 console.error('oh no.. this got triggered without any city data');
513 setMapBackground(data.city.id);
515 const servicesParsed: Record<LocationType, string[]> = {
521 data.locations.forEach(l => {
522 servicesParsed[l.type].push(`<a href="#" class="city-emit-event" data-event="${l.event_name}" data-args="${l.id}">${l.name}</a>`);
525 let html = `<h1>${data.city.name}</h1>
526 <div class="city-details">`;
528 if(servicesParsed.SERVICES.length) {
529 html += `<div><h3>Services</h3>${servicesParsed.SERVICES.join("<br>")}</div>`
531 if(servicesParsed.STORES.length) {
532 html += `<div><h3>Stores</h3>${servicesParsed.STORES.join("<br>")}</div>`
534 if(servicesParsed.EXPLORE.length) {
535 html += `<div><h3>Explore</h3>${servicesParsed.EXPLORE.join("<br>")}</div>`
540 ${data.paths.map(path => {
541 return `<a href="#" class="city-emit-event" data-event="city:travel" data-args="${path.ending_city}">${path.ending_city_name}</a>`
547 cache.set('currentMapHTML', html);
549 $('#map').html(html);
552 function renderRequirement(name: string, val: number | string, currentVal?: number): string {
553 let colorIndicator = '';
555 colorIndicator = currentVal >= val ? 'success' : 'error';
557 return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
560 function renderStatBoost(name: string, val: number | string): string {
561 let valSign: string = '';
562 if(typeof val === 'number') {
563 valSign = val > 0 ? '+' : '-';
565 return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
568 function renderInventoryItem(item: EquippedItemDetails , action: (item: EquippedItemDetails) => string): string {
569 return `<div class="store-list">
571 <img src="https://via.placeholder.com/64x64">
573 <div class="details">
574 <div class="name">${item.name}</div>
575 <div class="requirements">
576 ${item.requirements.level ? renderRequirement('LVL', item.requirements.level): ''}
577 ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength): ''}
578 ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution): ''}
579 ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity): ''}
580 ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence): ''}
581 ${renderRequirement('PRF', item.profession)}
583 <div class="stat-mods">
584 ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
585 ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
586 ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
587 ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
588 ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
589 ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
590 ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
592 ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
594 <div class="store-actions">
600 function renderShopEquipment(item: ShopEquipment, action: (item: ShopEquipment) => string): string {
601 const player: Player = cache.get('player');
602 return `<div class="store-list">
604 <img src="https://via.placeholder.com/64x64">
606 <div class="details">
607 <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
608 <div class="requirements">
609 ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
610 ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
611 ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
612 ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
613 ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
614 ${renderRequirement('PRF', item.profession)}
616 <div class="stat-mods">
617 ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
618 ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
619 ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
620 ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
621 ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
622 ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
623 ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp.toString())}
625 ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
627 <div class="store-actions">
633 function renderShopItem(item: (ShopItem & Item), action: (item: (ShopItem & Item)) => string): string {
634 const player: Player = cache.get('player');
635 return `<div class="store-list">
637 <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}">
639 <div class="details">
640 <div class="name">${item.name}</div>
641 <div class="requirements">
644 ${item.hasOwnProperty('id') ? `<div>${item.price_per_unit.toLocaleString()}G</div>` : ''}
646 <div class="store-actions">
652 socket.on('city:stores', (data: {equipment: ShopEquipment[], items: (ShopItem & Item)[]}) => {
653 const listing: Record<string, string> = {};
654 const listingTypes = new Set<string>();
655 const { equipment, items } = data;
656 equipment.forEach(item => {
657 if(item.type === 'ARMOUR') {
658 listingTypes.add(item.equipment_slot);
660 if(!listing[item.equipment_slot]) {
661 listing[item.equipment_slot] = '';
664 listing[item.equipment_slot] += renderShopEquipment(item, i => {
665 const id = `data-id="${i.id}"`;
666 return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
670 listingTypes.add(item.type);
671 if(!listing[item.type]) {
672 listing[item.type] = '';
675 listing[item.type] += renderShopEquipment(item, i => {
676 const id = `data-id="${i.id}"`;
677 return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
681 console.log('no type', item);
685 if(items && items.length) {
686 listingTypes.add('ITEMS');
687 listing['ITEMS'] = '';
688 items.forEach(item => {
689 listing['ITEMS'] += renderShopItem(item, i => {
690 return `<button type="button" class="trigger-modal" data-endpoint="/modal/location/${i.location_id}/purchase/items/${item.item_id}">Buy</button>`;
695 const sections = Object.keys(listing);
696 let activeSection = cache.get('active-shop-inventory-listing');
698 if(!sections.includes(activeSection)) {
699 activeSection = sections[0];
702 const nav: string[] = [];
703 const finalListing: string[] = [];
705 listingTypes.forEach(type => {
706 nav.push(`<a href="#" data-filter="${type}" class="${activeSection === type ? 'active': ''}">${capitalize(type)}</a>`);
707 finalListing.push(`<div class="filter-result ${activeSection === type ? 'active': 'hidden'}" id="filter_${type}">${listing[type]}</div>`);
710 let html = `<div class="shop-inventory-listing filter-container">
711 <nav class="filter" id="shop-inventory-listing">${nav.join(" ")}</nav><div class="inventory-listing listing">
712 ${finalListing.join("\n")}
716 $('#map').html(html);
720 $('body').on('click', '.purchase-item', e => {
724 const player = cache.get('player');
725 const cost = parseInt($(e.target).data('cost'));
728 const id = $(e.target).data('id');
729 if(player.gold < cost) {
730 events.emit('alert', [{
732 text: 'You don\'t have enough gold!'
737 const type = $(e.target).data('type');
738 socket.emit('purchase', {
744 $('body').on('click', '.http-post', async e => {
745 const $el = $(e.target).closest('[data-endpoint]');
746 const endpoint = $el.data('endpoint');
747 const args = $el.data();
749 const res = await http.post(endpoint, args);
752 $('body').on('click', '.emit-event', e => {
753 const $el = $(e.target);
755 const eventName = $el.closest('[data-event]').data('event');
756 const args = $el.closest('[data-args]').data('args');
758 const rawBlock = $el.data('block');
761 const block = parseInt(rawBlock) || 0;
763 if(block > Date.now()) {
764 Alert('Sorry, clicked too quick');
769 console.log(`Sending event ${eventName}`, { args });
770 socket.emit(eventName, { args });
773 $('body').on('click', '.city-emit-event', e => {
774 const $el = $(e.target);
776 const eventName = $el.data('event');
777 const args = $el.data('args');
779 console.log(`Sending event ${eventName}`, { args });
780 socket.emit(eventName, { args });
783 $('body').on('click', '.emit-event-internal', e => {
784 const $el = $(e.target);
786 const eventName = $el.data('event');
787 const args: string[] = $el.data('args')?.toString().split('|');
789 console.log(`Trigger internal event [${eventName}]`);
790 events.emit(eventName, args);
794 socket.on('explore:fights', (monsters: MonsterForList[]) => {
795 const lastSelectedMonster = cache.get('last-selected-monster');
796 if(monsters.length) {
797 let sel = `<form id="fight-selector"><select id="monster-selector">
798 ${monsters.map(monster => {
799 return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
801 </select> <button type="submit">Fight</button></option>`;
807 events.on('tab:explore', async () => {
808 const state = cache.get('state');
809 if(cache.get('player').hp <= 0 || (!state.fight && !state.travel)) {
810 // get the details of the city
812 let data: {city: City, locations: Location[], paths: Path[]};
813 if(!cache.has(('currentMapHTML')) || cache.get('player').hp <= 0) {
814 data = await http.get(`/city/${cache.get('player').city_id}`);
816 events.emit('renderMap', [data, state.travel]);
819 else if(state.fight) {
820 setMapBackground(state.closestTown);
821 $('#map').html(renderFight(state.fight));
823 else if(state.travel) {
825 events.emit('renderTravel', [state.travel]);
829 function updateStepButton() {
830 const raw = $('#keep-walking').data('block');
831 const time = parseInt(raw) || 0;
834 const wait = Math.ceil((time - Date.now())/ 1000);
836 $('#keep-walking').prop('disabled', true).html(`Keep walking (${wait}s)`);
837 setTimeout(updateStepButton, 300);
842 $('#keep-walking').prop('disabled', false).html(`Keep walking`);
845 function renderTravel(data: TravelDTO) {
846 setMapBackground(data.closestTown);
848 let promptText = data.walkingText;
849 const blockTime = data.nextAction || 0;
850 let html = `<div id="travelling">`;
852 html += '<div id="travelling-actions">';
853 html += `<button class="emit-event" id="keep-walking" data-event="travel:step" data-block="${blockTime}">Keep Walking</button>`;
855 if(data.things.length) {
856 // ok you found something, for now we only support
857 // monsters, but eventually that will change
858 promptText = `You see a ${data.things[0].name}`;
859 html += `<button class="emit-event-internal" data-event="startFight" data-args="${data.things[0].id}|travel">Fight</button>`;
862 // end #travelling-actions
864 html += `<p>${promptText}</p>`;
866 // end #travelling div
869 $('#map').html(html);
876 events.on('renderTravel', renderTravel);
878 function renderFight(monster: MonsterForFight | Fight) {
879 const hpPercent = Math.floor((monster.hp / monster.maxHp) * 100);
881 let html = `<div id="fight-container">
882 <div id="defender-info">
883 <div class="avatar-container">
884 <img id="avatar" src="https://via.placeholder.com/64x64">
886 <div id="defender-stat-bars">
887 <div id="defender-name">${monster.name}</div>
888 <div class="progress-bar" id="defender-hp-bar" style="background: linear-gradient(to right, red, red ${hpPercent}%, transparent ${hpPercent}%, transparent)" title="${hpPercent}% - ${monster.hp}/${monster.maxHp}">${hpPercent}% - ${monster.hp} / ${monster.maxHp}</div>
891 <div id="fight-actions">
892 <select id="fight-target">
893 <option value="head">Head</option>
894 <option value="body">Body</option>
895 <option value="arms">Arms</option>
896 <option value="legs">Legs</option>
898 <button type="button" class="fight-action" data-action="attack">Attack</button>
899 <button type="button" class="fight-action" data-action="cast">Cast</button>
900 <button type="button" class="fight-action" data-action="flee">Flee</button>
902 <div id="fight-results"></div>
908 socket.on('updatePlayer', (player: Player) => {
909 cache.set('player', player);
913 socket.on('fight-over', (data: {roundData: FightRound, monsters: MonsterForFight[]}) => {
914 const { roundData, monsters } = data;
915 cache.set('player', roundData.player);
918 $('#map').html(renderFight(roundData.monster));
920 let html: string[] = roundData.roundDetails.map(d => `<div>${d}</div>`);
922 if(roundData.winner === 'player') {
923 html.push(`<div>You defeated the ${roundData.monster.name}!</div>`);
924 if(roundData.rewards.gold) {
925 html.push(`<div>You gained ${roundData.rewards.gold} gold`);
927 if(roundData.rewards.exp) {
928 html.push(`<div>You gained ${roundData.rewards.exp} exp`);
930 if(roundData.rewards.levelIncrease) {
931 html.push(`<div>You gained a level! ${roundData.player.level}`);
935 if(roundData.player.hp === 0) {
936 // prompt to return to town and don't let them do anything
937 html.push(`<p>You were killed...</p>`);
938 html.push('<p><button class="emit-event-internal" data-event="tab:explore" data-args="">Back to Town</button></p>');
941 switch(roundData.fightTrigger) {
943 if(monsters.length) {
944 const lastSelectedMonster = cache.get('last-selected-monster');
945 // put this above the fight details
946 html.unshift(`<h2>Fight Again</h2><form id="fight-selector"><select id="monster-selector">
947 ${monsters.map(monster => {
948 return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
950 </select> <button type="submit">Fight</button></option>
951 </select></form><hr>`);
955 html.push(`<p><button class="emit-event" data-event="travel:step">Keep Walking</button></p>`);
958 console.error(`Unknown fight trigger [${roundData.fightTrigger}]`, roundData);
965 $('#fight-results').html(html.join("\n"));
966 $('#fight-actions').html('');
970 socket.on('fight-round', (data: FightRound) => {
971 $('.fight-action').prop('disabled', false);
972 cache.set('player', data.player);
975 $('#map').html(renderFight(data.monster));
977 $('#fight-results').html(data.roundDetails.map(d => `<div>${d}</div>`).join("\n"));
980 async function startFight(monsterId: string, fightTrigger: FightTrigger = 'explore') {
981 // check your HP first
982 if(cache.get('player').hp <= 0) {
983 events.emit('alert', [{
985 text: 'You don\'t have enough HP to go looking for a fight'
990 cache.set('last-selected-monster', monsterId);
993 const monster: MonsterForFight = await http.post('/fight', {
998 $('#map').html(renderFight(monster));
1001 events.emit('alert', [{
1003 text: 'Sorry, you can\'t start that fight'
1008 events.on('startFight', startFight);
1010 $('body').on('submit', '#fight-selector', async e => {
1012 e.stopPropagation();
1014 const monsterId = $('#monster-selector option:selected').val();
1017 startFight(monsterId.toString());
1020 console.error(`Invalid monster id [${monsterId}]`);
1025 $('body').on('click', '.fight-action', e => {
1027 e.stopPropagation();
1029 const action = $(e.target).data('action');
1030 const target = $('#fight-target option:selected').val();
1031 $('.fight-action').prop('disabled', true);
1033 socket.emit('fight', { action, target });
1036 $('body').on('submit', '#signup', async e => {
1038 e.stopPropagation();
1040 const data: Record<string, string> = {};
1042 $(e.target).serializeArray().forEach(v => data[v.name] = v.value);
1044 const res = await http.post('/signup', data);
1047 events.emit('alert', [{
1053 cache.set('player', res.player);
1055 $('#signup-prompt').remove();
1060 $('body').on('click', '#login', async e => {
1062 e.stopPropagation();
1064 const data: Record<string, string> = {};
1066 $(e.target).closest('form').serializeArray().forEach(v => data[v.name] = v.value);
1068 if(data.username && data.password) {
1069 const res = await http.post('/login', data);
1071 events.emit('alert', [{type: 'error', text: res.error}]);
1074 localStorage.setItem('authToken', res.player.id);
1075 window.location.reload();
1080 $('body').on('click', '#logout', async e => {
1082 e.stopPropagation();
1086 const player = cache.get('player');
1087 if(/^Player[\d]+$/.test(player.username)) {
1088 const clear = confirm('Are you sure? You will not be able to retrieve this character unless you set up an email/password');
1101 socket.emit('logout');
1102 localStorage.clear();
1103 window.location.reload();
1108 function bootstrap() {
1109 console.log('Server connection verified');
1110 //socket.emit('inventory');
1111 $('nav a').first().click();
1114 document.body.addEventListener('htmx:configRequest', function(evt) {
1116 evt.detail.headers['x-authtoken'] = authToken();