1 import { io } from 'socket.io-client';
2 import $ from 'jquery';
3 import {expToLevel, maxHp, Player} from '../shared/player';
4 import { authToken, http } from './http';
5 import { CustomEventManager } from './events';
6 import {Fight, 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, ShopItem} from '../shared/inventory';
11 import { 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';
20 const time = new TimeManager();
21 const cache = new Map<string, any>();
22 const events = new CustomEventManager();
25 'x-authtoken': authToken()
29 configureChat(socket);
31 function setTimeGradient() {
32 const gradientName = time.gradientName();
33 const $body = $('body');
34 $body.removeClass(Array.from($body[0].classList)).addClass(gradientName);
36 $body.addClass('night');
41 setInterval(setTimeGradient, 60 * 1000);
43 function icon(name: string): string {
44 return `<img src="/assets/img/${name}.png" class="icon">`;
48 function generateProgressBar(current: number, max: number, color: string, displayPercent: boolean = true): string {
51 percent = Math.floor((current / max) * 100);
53 const display = `${displayPercent? `${percent}% - `: ''}`;
54 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>`;
57 function progressBar(current: number, max: number, el: string, color: string) {
60 percent = Math.floor((current / max) * 100);
64 `linear-gradient(to right, ${color}, ${color} ${percent}%, transparent ${percent}%, transparent)`
65 ).attr('title', `${percent}% - ${current}/${max}`).html(`${current}/${max} - ${percent}%`);
68 function updatePlayer() {
69 const player: Player = cache.get('player');
71 $('#username').html(`${player.username}, level ${player.level} ${player.profession}`);
75 maxHp(player.constitution, player.level),
82 expToLevel(player.level + 1),
87 ['strength', 'constitution', 'dexterity', 'intelligence','hp','exp'].forEach(s => {
88 $(`.${s}`).html(player[s]);
91 $('.maxHp').html(maxHp(player.constitution, player.level).toString());
92 $('.expToLevel').html(expToLevel(player.level + 1).toString());
93 $('.gold').html(player.gold.toLocaleString());
95 if(player.account_type === 'session') {
96 $('#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>
98 <div class="form-group">
99 <label>Username:</label>
100 <input type="text" name="username">
102 <div class="form-group">
103 <label>Password:</label>
104 <input type="password" name="password">
106 <button type="submit" class="success">Create your Account</button>
107 <button type="button" id="login">Login To Existing Account</button>
108 </form></p>`).removeClass('hidden');
113 socket.on('connect', () => {
114 console.log(`Connected: ${socket.id}`);
117 socket.on('ready', () => {
118 console.log('Server connection verified');
119 socket.emit('inventory');
122 socket.on('server-stats', (data: {onlinePlayers: number}) => {
123 $('#server-stats').html(`${data.onlinePlayers} players online`);
126 each(EventList, event => {
127 console.log(`Binding Event ${event.eventName}`);
128 if(event instanceof SocketEvent) {
129 socket.on(event.eventName, event.handler.bind(null, {
136 console.log('Skipped binding', event);
140 socket.on('calc:ap', (data: {ap: Record<EquipmentSlot, { currentAp: number, maxAp: number}>}) => {
145 ${generateProgressBar(ap.HEAD?.currentAp || 0, ap.HEAD?.maxAp || 0, '#7be67b')}
149 ${generateProgressBar(ap.ARMS?.currentAp || 0, ap.ARMS?.maxAp || 0, '#7be67b')}
153 ${generateProgressBar(ap.CHEST?.currentAp || 0, ap.CHEST?.maxAp || 0, '#7be67b')}
157 ${generateProgressBar(ap.LEGS?.currentAp || 0, ap.LEGS?.maxAp || 0, '#7be67b')}
160 $('#ap-bar').html(html);
163 events.on('tab:skills', () => {
164 $('#skills').html('');
165 socket.emit('skills');
168 socket.on('skills', (data: {skills: Skill[]}) => {
169 let html = `<table id="skill-list">
170 ${data.skills.map((skill: Skill) => {
171 const definition = Skills.get(skill.id);
172 const percent = skill.exp / definition.expToLevel(skill.level + 1);
175 <td class="skill-level">${skill.level.toLocaleString()}</td>
176 <td class="skill-description" title="Total Exp: ${skill.exp.toLocaleString()}/${definition.expToLevel(skill.level + 1).toLocaleString()}">
177 <span class="skill-exp">${(percent * 100).toPrecision(2)}% to next level</span>
178 <b>${definition.display}</b>
179 <p>${definition.description}</p>
186 $('#skills').html(html);
189 events.on('alert', (data: {type: string, text: string}) => {
191 $('#alerts').append(`<div class="alert ${data.type}" id="alert-${id}">${data.text}</div>`);
194 $(`#alert-${id}`).remove();
199 socket.on('alert', data => {
200 events.emit('alert', [data]);
203 socket.on('authToken', (authToken: string) => {
204 console.log(`recv auth token ${authToken}`);
205 localStorage.setItem('authToken', authToken);
209 socket.on('player', (player: Player) => {
210 cache.set('player', player);
215 $('nav a').on('click', e => {
219 const $tabContainer = $(`#${$(e.target).data('container')}`);
221 $tabContainer.find('.tab').removeClass('active');
223 $(`#${$(e.target).data('section')}`).addClass('active');
225 $(e.target).closest('nav').find('a').removeClass('active');
226 $(e.target).addClass('active');
228 events.emit(`tab:${$(e.target).data('section')}`);
231 events.on('tab:inventory', () => {
232 socket.emit('inventory');
235 function renderEquipmentPlacementGrid(items: EquippedItemDetails[]) {
236 const placeholder = 'https://via.placeholder.com/64x64';
238 const map: Record<EquipmentSlot, EquippedItemDetails> = items.filter(item => item.is_equipped).reduce((acc, item) => {
239 acc[item.equipment_slot] = item;
244 <table id="character-equipment-placement">
248 <td style="background-image: url('${placeholder}');" title="${map.HEAD ? map.HEAD.name : 'Empty'}">
249 ${map.HEAD ? map.HEAD.name : 'HEAD'}
251 <td style="background-image: url('${placeholder}');" title="${map.ARMS ? map.ARMS.name : 'Empty'}">
252 ${map.ARMS ? map.ARMS.name : 'ARMS'}
256 <td style="background-image: url('${placeholder}');" title="${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
257 ${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'L_HAND')}
259 <td style="background-image: url('${placeholder}');" title="${map.CHEST ? map.CHEST.name : ''}">
260 ${map.CHEST ? map.CHEST.name : 'CHEST'}
262 <td style="background-image: url('${placeholder}');" title="${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
263 ${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'R_HAND')}
269 <td style="background-image: url('${placeholder}');" title="${map.LEGS ? map.LEGS.name : ''}">
270 ${map.LEGS ? map.LEGS.name : 'LEGS'}
280 function renderStatDetails() {
281 const player: Player = cache.get('player');
283 <table id="stat-breakdown">
286 <td class="strength">${player.strength}</td>
289 <th>Constitution</th>
290 <td class="constitution">${player.constitution}</td>
294 <td class="dexterity">${player.dexterity}</td>
297 <th>Intelligence</th>
298 <td class="intelligence">${player.intelligence}</td>
305 $('body').on('click', 'nav.filter', e => {
309 const $target = $(e.target);
310 const filter = $target.data('filter');
312 $('.filter-result').removeClass('active').addClass('hidden');
313 $(`#filter_${filter}`).addClass('active').removeClass('hidden');
315 $target.closest('nav').find('a').removeClass('active');
316 $target.addClass('active');
318 cache.set('active-inventory-section', filter);
321 function renderInventorySection(inventory: EquippedItemDetails[]): string {
322 return inventory.map(item => {
323 return renderInventoryItem(item, item => {
324 if(item.is_equipped) {
325 return `<button type="button" class="unequip-item error" data-id="${item.item_id}">Unequip</button>`;
328 if(item.equipment_slot === 'ANY_HAND') {
329 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="LEFT_HAND">Equip L</button>
330 <button type="button" class="equip-item" data-id="${item.item_id}" data-slot="RIGHT_HAND">Equip R</button>`;
332 else if(item.equipment_slot === 'LEFT_HAND') {
333 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Left</button>`;
335 else if(item.equipment_slot === 'RIGHT_HAND') {
336 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Right</button>`;
338 else if(item.equipment_slot === 'TWO_HANDED') {
339 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
342 return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
349 socket.on('inventory', (data: {inventory: EquippedItemDetails[]}) => {
350 const player: Player = cache.get('player');
351 const activeSection = cache.get('active-inventory-section') || 'ARMOUR';
352 // split the inventory into sections!
353 const sectionedInventory: {ARMOUR: EquippedItemDetails[], WEAPON: EquippedItemDetails[], SPELL: EquippedItemDetails[]} = {
359 data.inventory.forEach(item => {
360 sectionedInventory[item.type].push(item);
364 <div id="inventory-page">
365 <div id="character-summary">
366 ${renderEquipmentPlacementGrid(data.inventory)}
367 <div id="extra-inventory-info">
368 ${renderStatDetails()}
371 <div id="inventory-section">
373 <a href="#" data-filter="ARMOUR" class="${activeSection === 'ARMOUR' ? 'active': ''}">Armour</a>
374 <a href="#" data-filter="WEAPON" class="${activeSection === 'WEAPON' ? 'active': ''}">Weapons</a>
375 <a href="#" data-filter="SPELL" class="${activeSection === 'SPELL' ? 'active': ''}">Spells</a>
377 <div class="inventory-listing">
378 <div class="filter-result ${activeSection === 'ARMOUR' ? 'active' : 'hidden'}" id="filter_ARMOUR">
379 ${renderInventorySection(sectionedInventory.ARMOUR)}
381 <div class="filter-result ${activeSection === 'WEAPON' ? 'active' : 'hidden'}" id="filter_WEAPON">
382 ${renderInventorySection(sectionedInventory.WEAPON)}
384 <div class="filter-result ${activeSection === 'SPELL' ? 'active' : 'hidden'}" id="filter_SPELL">
385 ${renderInventorySection(sectionedInventory.SPELL)}
391 $('#inventory').html(html);
394 $('body').on('click', '.equip-item', e => {
398 const $target = $(e.target);
399 const id = $target.data('id');
400 const slot = $target.data('slot');
402 socket.emit('equip', {id, slot});
405 $('body').on('click', '.unequip-item', e => {
409 socket.emit('unequip', { id: $(e.target).data('id') });
412 events.on('renderMap', async function renderMap(data: { city: City, locations: Location[], paths: Path[]}) {
413 if(!data || cache.has('currentMapHTML')) {
414 $('#map').html(cache.get('currentMapHTML'));
419 console.error('oh no.. this got triggered without any city data');
423 '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/${data.city.id}.jpeg")`
427 const servicesParsed: Record<LocationType, string[]> = {
433 data.locations.forEach(l => {
434 servicesParsed[l.type].push(`<a href="#" class="city-emit-event" data-event="${l.event_name}" data-args="${l.id}">${l.name}</a>`);
437 let html = `<h1>${data.city.name}</h1>
438 <div class="city-details">`;
440 if(servicesParsed.SERVICES.length) {
441 html += `<div><h3>Services</h3>${servicesParsed.SERVICES.join("<br>")}</div>`
443 if(servicesParsed.STORES.length) {
444 html += `<div><h3>Stores</h3>${servicesParsed.STORES.join("<br>")}</div>`
446 if(servicesParsed.EXPLORE.length) {
447 html += `<div><h3>Explore</h3>${servicesParsed.EXPLORE.join("<br>")}</div>`
452 ${data.paths.map(path => {
453 return `<a href="#" class="city-emit-event" data-event="city:travel" data-args="${path.ending_city}">${path.ending_city_name}</a>`
459 cache.set('currentMapHTML', html);
461 $('#map').html(html);
464 function renderRequirement(name: string, val: number | string, currentVal?: number): string {
465 let colorIndicator = '';
467 colorIndicator = currentVal >= val ? 'success' : 'error';
469 return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
472 function renderStatBoost(name: string, val: number | string): string {
473 let valSign: string = '';
474 if(typeof val === 'number') {
475 valSign = val > 0 ? '+' : '-';
477 return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' && val > 0 ? "success": "error"}">${valSign}${val}</span>`;
480 function renderInventoryItem(item: EquippedItemDetails , action: (item: EquippedItemDetails) => string): string {
481 return `<div class="store-list">
483 <img src="https://via.placeholder.com/64x64">
485 <div class="details">
486 <div class="name">${item.name}</div>
487 <div class="requirements">
488 ${item.requirements.level ? renderRequirement('LVL', item.requirements.level): ''}
489 ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength): ''}
490 ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution): ''}
491 ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity): ''}
492 ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence): ''}
493 ${renderRequirement('PRF', item.profession)}
495 <div class="stat-mods">
496 ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
497 ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
498 ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
499 ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
500 ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
501 ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
503 ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
505 <div class="store-actions">
511 function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): string {
512 const player: Player = cache.get('player');
513 return `<div class="store-list">
515 <img src="https://via.placeholder.com/64x64">
517 <div class="details">
518 <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
519 <div class="requirements">
520 ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
521 ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
522 ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
523 ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
524 ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
525 ${renderRequirement('PRF', item.profession)}
527 <div class="stat-mods">
528 ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
529 ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
530 ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
531 ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
532 ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
533 ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp)}
535 ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
537 <div class="store-actions">
544 socket.on('city:stores', (data: ShopItem[]) => {
545 let html = `<div class="shop-inventory-listing">
547 return renderShopItem(item, i => {
548 const id = `data-id="${i.id}"`;
549 return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
555 $('#map').html(html);
559 $('body').on('click', '.purchase-item', e => {
563 const player = cache.get('player');
564 const cost = parseInt($(e.target).data('cost'));
567 const id = $(e.target).data('id');
568 if(player.gold < cost) {
569 events.emit('alert', [{
571 text: 'You don\'t have enough gold!'
576 const type = $(e.target).data('type');
577 socket.emit('purchase', {
583 $('body').on('click', '.city-emit-event', e => {
584 const $el = $(e.target);
586 const eventName = $el.data('event');
587 const args = $el.data('args');
589 console.log(`Sending event ${eventName}`, { args });
590 socket.emit(eventName, { args });
594 socket.on('explore:fights', (monsters: MonsterForList[]) => {
595 const lastSelectedMonster = cache.get('last-selected-monster');
596 if(monsters.length) {
597 let sel = `<form id="fight-selector"><select id="monster-selector">
598 ${monsters.map(monster => {
599 return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
601 </select> <button type="submit">Fight</button></option>`;
607 events.on('tab:explore', async () => {
608 const res = await http.get('/fight');
610 // get the details of the city
612 let data: {city: City, locations: Location[], paths: Path[]};
613 if(!cache.has(('currentMapHTML'))) {
614 data = await http.get(`/city/${cache.get('player').city_id}`);
616 events.emit('renderMap', [data]);
619 $('#map').html(renderFight(res));
623 function renderFight(monster: MonsterForFight | Fight) {
624 const hpPercent = Math.floor((monster.hp / monster.maxHp) * 100);
626 let html = `<div id="fight-container">
627 <div id="defender-info">
628 <div class="avatar-container">
629 <img id="avatar" src="https://via.placeholder.com/64x64">
631 <div id="defender-stat-bars">
632 <div id="defender-name">${monster.name}</div>
633 <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>
636 <div id="fight-actions">
637 <select id="fight-target">
638 <option value="head">Head</option>
639 <option value="body">Body</option>
640 <option value="arms">Arms</option>
641 <option value="legs">Legs</option>
643 <button type="button" class="fight-action" data-action="attack">Attack</button>
644 <button type="button" class="fight-action" data-action="cast">Cast</button>
645 <button type="button" class="fight-action" data-action="flee">Flee</button>
647 <div id="fight-results"></div>
653 socket.on('updatePlayer', (player: Player) => {
654 cache.set('player', player);
658 socket.on('fight-over', (data: {roundData: FightRound, monsters: MonsterForFight[]}) => {
659 const { roundData, monsters } = data;
660 cache.set('player', roundData.player);
663 $('#map').html(renderFight(roundData.monster));
665 let html: string[] = roundData.roundDetails.map(d => `<div>${d}</div>`);
667 if(roundData.winner === 'player') {
668 html.push(`<div>You defeated the ${roundData.monster.name}!</div>`);
669 if(roundData.rewards.gold) {
670 html.push(`<div>You gained ${roundData.rewards.gold} gold`);
672 if(roundData.rewards.exp) {
673 html.push(`<div>You gained ${roundData.rewards.exp} exp`);
675 if(roundData.rewards.levelIncrease) {
676 html.push(`<div>You gained a level! ${roundData.player.level}`);
680 if(monsters.length) {
681 const lastSelectedMonster = cache.get('last-selected-monster');
682 // put this above the fight details
683 html.unshift(`<h2>Fight Again</h2><form id="fight-selector"><select id="monster-selector">
684 ${monsters.map(monster => {
685 return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
687 </select> <button type="submit">Fight</button></option>
688 </select></form><hr>`);
691 $('#fight-results').html(html.join("\n"));
692 $('#fight-actions').html('');
696 socket.on('fight-round', (data: FightRound) => {
697 $('.fight-action').prop('disabled', false);
698 cache.set('player', data.player);
701 $('#map').html(renderFight(data.monster));
703 $('#fight-results').html(data.roundDetails.map(d => `<div>${d}</div>`).join("\n"));
706 $('body').on('submit', '#fight-selector', async e => {
710 const monsterId = $('#monster-selector option:selected').val();
712 cache.set('last-selected-monster', monsterId);
714 const monster: MonsterForFight = await http.post('/fight', {
719 $('#map').html(renderFight(monster));
722 $('body').on('click', '.fight-action', e => {
726 const action = $(e.target).data('action');
727 const target = $('#fight-target option:selected').val();
728 $('.fight-action').prop('disabled', true);
730 socket.emit('fight', { action, target });
733 $('body').on('submit', '#signup', async e => {
737 const data: Record<string, string> = {};
739 $(e.target).serializeArray().forEach(v => data[v.name] = v.value);
741 const res = await http.post('/signup', data);
744 events.emit('alert', [{
750 cache.set('player', res.player);
752 $('#signup-prompt').remove();
757 $('body').on('click', '#login', async e => {
761 const data: Record<string, string> = {};
763 $(e.target).closest('form').serializeArray().forEach(v => data[v.name] = v.value);
765 if(data.username && data.password) {
766 const res = await http.post('/login', data);
768 events.emit('alert', [{type: 'error', text: res.error}]);
771 localStorage.setItem('authToken', res.player.id);
772 window.location.reload();