Archive commit

This commit is contained in:
Erik McClure
2019-08-28 21:15:02 -07:00
commit 735845746e
1435 changed files with 140724 additions and 0 deletions
@@ -0,0 +1,19 @@
.action-bar(
[class.has-scroller]="hasScroller" [class.is-mobile]="mobile" [class.is-blurred]="blurred"
#scroller (mousewheel)="scroll($event)" (scroll)="true")
action-button(
*ngFor="let a of actions; let i = index"
[class.blur-me]="i < blurCount"
[action]="a.action"
[active]="a.action && activeAction === a"
[editable]="editable"
[shortcut]="shortcuts[i] || ''"
(use)="use(a.action)"
[draggableItem]="a.action"
[draggableDisabled]="!editable || !a.action"
[draggablePad]="10"
(draggableDrag)="drag(i)"
(draggableDrop)="drop($event, i)")
.action-button-padding
.scroller-label
| scroll using this bar
@@ -0,0 +1,72 @@
@import '../../../../styles/partials/variables';
:host {
display: block;
pointer-events: none;
}
.action-bar {
overflow: hidden;
padding-bottom: 7px;
padding-top: 7px;
padding-right: 5px;
max-width: calc(100vw - 50px);
display: flex;
&.is-mobile {
overflow-x: scroll;
overflow-y: hidden;
pointer-events: auto;
}
&.is-blurred > .blur-me {
filter: blur(3px);
}
// &.is-blurred:not(.is-mobile) > :nth-child(-n+11) {
// filter: blur(3px);
// }
// &.is-blurred.is-mobile > :nth-child(-n+9) {
// filter: blur(3px);
// }
&.has-scroller {
padding-top: 25px;
min-width: calc(100vw - 50px);
}
}
.action-button-padding {
min-width: 1px;
width: 1px;
height: 1px;
}
.scroller-label {
display: none;
.has-scroller + & {
display: block;
background: #111;
position: absolute;
left: 0;
top: 0;
right: 0;
padding: 1px;
margin-top: 5px;
text-align: center;
color: $text-muted;
}
}
action-button {
pointer-events: auto;
margin: 3px 6px;
.is-mobile & {
margin-left: 10px;
margin-right: 10px;
margin-top: 15px;
}
}
@@ -0,0 +1,84 @@
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
import { ButtonAction } from '../../../common/interfaces';
import { PonyTownGame } from '../../../client/game';
import { isMobile } from '../../../client/data';
import { useAction, serializeActions } from '../../../client/buttonActions';
import { SettingsService } from '../../services/settingsService';
import { ACTIONS_LIMIT } from '../../../common/constants';
import { last } from '../../../common/utils';
@Component({
selector: 'action-bar',
templateUrl: 'action-bar.pug',
styleUrls: ['action-bar.scss'],
})
export class ActionBar {
@ViewChild('scroller', { static: true }) scroller!: ElementRef;
@Input() blurred = false;
activeAction: ButtonAction | undefined = undefined;
shortcuts = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='];
private _editable = false;
constructor(private game: PonyTownGame, private settings: SettingsService) {
}
@Input() get editable() {
return this._editable;
}
set editable(value) {
if (this._editable !== value) {
this._editable = value;
this.updateFreeSlots();
if (!value) {
this.save();
}
}
}
get actions() {
return this.game.actions;
}
get mobile() {
return isMobile;
}
get hasScroller() {
return this.editable && isMobile;
}
get blurCount() {
const boxWidth = isMobile ? 50 : 40;
const width = 450 + this.scroller.nativeElement.scrollLeft;
return Math.floor(width / boxWidth);
}
use(action: ButtonAction | undefined) {
useAction(this.game, action);
}
drag(index: number) {
this.actions[index].action = undefined;
this.updateFreeSlots();
}
drop(action: ButtonAction | undefined, index: number) {
this.actions[index].action = action;
this.updateFreeSlots();
}
save() {
const settings = { ...this.settings.account, actions: serializeActions(this.actions) };
this.settings.saveAccountSettings(settings);
}
scroll(e: MouseWheelEvent) {
if (e.deltaY) {
const delta = e.deltaY > 0 ? 1 : -1;
this.scroller.nativeElement.scrollLeft += delta * 20;
}
}
private updateFreeSlots() {
const actions = this.actions;
if (this.editable) {
while (actions.length < 5 || (last(actions)!.action !== undefined && actions.length < ACTIONS_LIMIT)) {
actions.push({ action: undefined });
}
} else {
while (actions.length > 0 && last(actions)!.action === undefined) {
actions.pop();
}
}
}
}
@@ -0,0 +1,6 @@
button.action-button(
(click)="click()" [class.active]="active" [class.no-shadow]="!shadow" [title]="action && action.title || ''")
canvas(#canvas width="29" height="29")
.shortcut {{shortcut}}
.count(*ngIf="action && action.type === 'item'") {{action.count}}
.cover
@@ -0,0 +1,95 @@
@import '../../../../styles/partials/variables';
$button-size: 29px;
$border-radius: 8px;
$default-box-shadow: 0 0 3px rgba(0, 0, 0, 0.7) !important;
$hover-box-shadow: 0 0 7px rgba(0, 0, 0, 0.8) !important;
$pressed-box-shadow: 0 0 7px rgba(0, 0, 0, 1) !important;
:host {
display: block;
width: $button-size;
height: $button-size;
&.draggable-hover {
box-shadow: 0 0 5px 1px white;
border-radius: 4px;
}
&.empty {
visibility: hidden;
}
}
.action-button {
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
box-shadow: $default-box-shadow;
position: relative;
user-select: none;
&:hover, &:focus:hover {
box-shadow: $hover-box-shadow;
}
&:focus {
outline: none;
box-shadow: $default-box-shadow;
}
&:active, &:hover:active, &:focus:hover:active {
box-shadow: $pressed-box-shadow, inset 0 0 0 black;
> canvas {
opacity: 0.8;
}
}
&.active {
border-color: white;
}
&.no-shadow, &:focus.no-shadow, &:focus:hover.no-shadow {
box-shadow: none !important;
}
}
canvas {
display: block;
border-radius: 4px;
}
.shortcut {
pointer-events: none;
position: absolute;
top: -4px;
right: -2px;
font-size: 9px;
color: white;
font-weight: bold;
text-shadow: 0px 0px 1px black, 0px 0px 1px black, 0px 0px 1px black,
0px 0px 1px black, 0px 0px 1px black;
}
.count {
pointer-events: none;
position: absolute;
bottom: -7px;
right: -2px;
font-size: 12px;
color: white;
font-weight: bold;
text-shadow: 0px 0px 1px black, 0px 0px 1px black, 0px 0px 1px black,
0px 0px 1px black, 0px 0px 1px black;
}
.cover {
position: absolute;
left: -5px;
top: -5px;
right: -5px;
bottom: -5px;
}
@@ -0,0 +1,46 @@
import { Component, Input, ViewChild, ElementRef, ChangeDetectionStrategy, Output, EventEmitter } from '@angular/core';
import { ButtonAction } from '../../../common/interfaces';
import { PonyTownGame, actionButtons } from '../../../client/game';
import { drawAction } from '../../../client/buttonActions';
import { removeItem } from '../../../common/utils';
@Component({
selector: 'action-button',
templateUrl: 'action-button.pug',
styleUrls: ['action-button.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.empty]': '!editable && !action',
},
})
export class ActionButton {
@Input() action?: ButtonAction;
@Input() editable = false;
@Input() active = false;
@Input() shadow = true;
@Input() shortcut = '';
@Output() use = new EventEmitter<ButtonAction>();
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
dirty = true;
private state: any = {};
constructor(private game: PonyTownGame) {
}
ngOnInit() {
actionButtons.push(this);
}
ngOnDestroy() {
removeItem(actionButtons, this);
}
ngOnChanges() {
this.dirty = true;
}
click() {
if (this.action) {
this.use.emit(this.action);
}
}
draw() {
drawAction(this.canvas.nativeElement, this.action, this.state, this.game);
this.dirty = false;
}
}
@@ -0,0 +1,97 @@
.modal-header.d-none.d-sm-block.d-none-for-low-height
h4.modal-title(labelledBy=".modal")
| Actions
.modal-body.modal-checkboxes
tabset.fixed-height(
type="pills" saveActiveTab="testing-save-tab" [destroyOnHide]="false"
(activeIndexChange)="tabIndex = $event")
tab(title="Actions" [icon]="actionsIcon")
.action-columns(*tabContent)
.d-flex.align-items-center.mb-2(*ngFor="let a of actions")
action-button.mb-0.mr-2([draggableItem]="a" [action]="a" [shadow]="false")
b {{a.title}}
tab(title="Expressions" [icon]="expressionsIcon")
div(*tabContent)
.d-flex.mb-3
action-button.mb-0.mr-sm-4(
[draggableItem]="emoteAction" [action]="emoteAction" [shadow]="false" (draggableDrop)="drop($event)")
.sub-tabset.nav.nav-pills.mb-0
button.btn-unstyled.nav-link([class.active]="activeTab === 'right-eye'" (click)="activeTab = 'right-eye'") Right eye
button.btn-unstyled.nav-link([class.active]="activeTab === 'left-eye'" (click)="activeTab = 'left-eye'") Left eye
button.btn-unstyled.nav-link([class.active]="activeTab === 'mouth'" (click)="activeTab = 'mouth'") Mouth
button.btn-unstyled.nav-link([class.active]="activeTab === 'extra'" (click)="activeTab = 'extra'") Extra
.fixed-height-tabset
div([class.inactive-tab]="activeTab !== 'right-eye'")
sprite-selection(
[(selected)]="eyeRight" [sprites]="eyesRight" [fill]="eyeColor"
[circle]="coatFill" [reverseExtra]="true" [skip]="1"
[invisible]="tabIndex !== 1 || activeTab !== 'right-eye'"
(selectedChange)="changed(lockEyes)")
div([class.inactive-tab]="activeTab !== 'left-eye'")
custom-checkbox.mb-2([(checked)]="lockEyes" (checkedChange)="changed($event)")
| Use the same as right one
sprite-selection(
[(selected)]="eyeLeft" [sprites]="eyesLeft" [fill]="eyeColor" [disabled]="lockEyes"
[circle]="coatFill" [reverseExtra]="true" [skip]="1"
[invisible]="tabIndex !== 1 || activeTab !== 'left-eye'"
(selectedChange)="changed(lockEyes)")
div([class.inactive-tab]="activeTab !== 'mouth'")
sprite-selection#expression-selection(
[(selected)]="muzzle" [sprites]="muzzles" [fill]="noseFills"
[outline]="noseOutlines" [circle]="coatFill"
[invisible]="tabIndex !== 1 || activeTab !== 'mouth'"
(selectedChange)="changed(lockEyes)")
div([class.inactive-tab]="activeTab !== 'extra'")
sprite-selection.mb-2(
[(selected)]="irisRight" [sprites]="irisesRight" [fill]="eyeColor"
[invisible]="tabIndex !== 1 || activeTab !== 'extra'"
[circle]="coatFill" [reverseExtra]="true" (selectedChange)="changed(lockEyes)")
custom-checkbox([(checked)]="lockIrises" (checkedChange)="changed(lockEyes)")
| Use the same as right one
sprite-selection.mt-2(
[(selected)]="irisLeft" [sprites]="irisesLeft" [fill]="eyeColor" [disabled]="lockIrises"
[invisible]="tabIndex !== 1 || activeTab !== 'extra'"
[circle]="coatFill" [reverseExtra]="true" (selectedChange)="changed(lockEyes)")
.d-flex.flex-wrap.mt-3
label.text-muted.mr-3 Other options
custom-checkbox([(checked)]="blush" (checkedChange)="changed(lockEyes)") Blush
custom-checkbox.ml-4([(checked)]="sleeping" (checkedChange)="changed(lockEyes)") Sleeping
custom-checkbox.ml-4([(checked)]="tears" (checkedChange)="changed(lockEyes)") Tears
custom-checkbox.ml-4([(checked)]="crying" (checkedChange)="changed(lockEyes)") Crying
custom-checkbox.ml-4([(checked)]="hearts" (checkedChange)="changed(lockEyes)") Hearts
tab(title="Chat" [icon]="chatIcon")
.action-columns(*tabContent)
.d-flex.align-items-center.mb-2(*ngFor="let a of commands")
action-button.mb-0.mr-2([draggableItem]="a" [action]="a" [shadow]="false")
b {{a.title}}
tab(title="Options" [icon]="optionsIcon")
div(*tabContent)
button.btn.btn-outline-secondary.mr-2.mb-2((click)="resetToDefault()")
| Reset to default actions
button.btn.btn-outline-secondary.mr-2.mb-2((click)="clearActionBar()")
| Clear action bar
button.btn.btn-outline-secondary.mr-2.mb-2((click)="undo()")
| Undo
tab(*ngIf="dev" title="Dev" [icon]="devIcon")
div(*tabContent)
.d-flex.mb-3
action-button.mb-0.mr-4(
[draggableItem]="entityAction" [action]="entityAction" [shadow]="false" (draggableDrop)="drop($event)")
input.form-control(
placeholder="entity name" [(ngModel)]="entityName" (ngModelChange)="updateEntity()" style="max-width: 200px;")
.d-flex.flex-wrap(style="height: 250px; overflow-y: scroll")
action-button(*ngFor="let a of entityActions" [draggableItem]="a" [action]="a")
.modal-footer.d-flex
.flex-grow-1
em.text-muted Drag actions to the action bar
button.btn.btn-outline-secondary((click)="ok()")
| Close
@@ -0,0 +1,40 @@
@import '../../../../styles/partials/variables';
action-button {
margin-right: 6px;
margin-bottom: 6px;
}
.fixed-height-tabset {
display: grid;
> div {
grid-row: 1 / 2;
grid-column: 1 / 2;
}
}
.sub-tabset {
font-size: 13px;
@media screen and (max-width: 420px) {
.nav-link {
margin-right: 0.1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
}
.action-columns {
display: flex;
flex-wrap: wrap;
> div {
width: 50%;
}
}
.inactive-tab {
visibility: hidden;
}
@@ -0,0 +1,154 @@
import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import {
createButtionActionActions, expressionButtonAction, createButtonCommandActions,
createDefaultButtonActions, actionExpressionDefaultPalette, entityButtonAction
} from '../../../client/buttonActions';
import * as sprites from '../../../generated/sprites';
import {
Eye, Muzzle, ColorExtraSet, ColorExtra, Iris, ExpressionExtra, PonyEye, ButtonAction, Action,
ButtonActionSlot, EntityButtonAction
} from '../../../common/interfaces';
import { createExpression } from '../../../client/clientUtils';
import { ACTION_EXPRESSION_BG, ACTION_EXPRESSION_EYE_COLOR, fillToOutline } from '../../../common/colors';
import { faLock, faApple, faLaughBeam, faComment, faCog, faCogs } from '../../../client/icons';
import { createEyeSprite } from '../../../client/spriteUtils';
import { times, hasFlag } from '../../../common/utils';
import { PonyTownGame } from '../../../client/game';
import { getEntityNames } from '../../services/model';
function eyeSprite(e: PonyEye | undefined) {
return createEyeSprite(e, 0, sprites.defaultPalette);
}
@Component({
selector: 'actions-modal',
templateUrl: 'actions-modal.pug',
styleUrls: ['actions-modal.scss'],
})
export class ActionsModal implements OnInit, OnDestroy {
readonly lockIcon = faLock;
readonly actionsIcon = faApple;
readonly expressionsIcon = faLaughBeam;
readonly chatIcon = faComment;
readonly optionsIcon = faCog;
readonly devIcon = faCogs;
readonly dev = BETA;
@Output() close = new EventEmitter();
actions = createButtionActionActions();
commands = createButtonCommandActions();
emoteAction = expressionButtonAction(createExpression(Eye.Neutral, Eye.Neutral, Muzzle.Smile));
entityAction = entityButtonAction('apple');
entityActions: EntityButtonAction[] = [];
entityName = 'apple';
lockEyes = true;
lockIrises = true;
eyesLeft: ColorExtraSet = sprites.eyeLeft.map(e => e && e[0]).map(eyeSprite);
eyesRight: ColorExtraSet = sprites.eyeRight.map(e => e && e[0]).map(eyeSprite);
irisesLeft: ColorExtraSet = times(Iris.COUNT, i => createEyeSprite(sprites.eyeLeft[1]![0]!, i, sprites.defaultPalette));
irisesRight: ColorExtraSet = times(Iris.COUNT, i => createEyeSprite(sprites.eyeRight[1]![0]!, i, sprites.defaultPalette));
muzzles: ColorExtraSet = sprites.noses
.map(n => n[0][0])
.map(({ color, colors, mouth }) => ({
color, colors, extra: mouth, palettes: [actionExpressionDefaultPalette.colors]
} as ColorExtra));
noseFills = [ACTION_EXPRESSION_BG];
noseOutlines = [fillToOutline(ACTION_EXPRESSION_BG)];
coatFill = ACTION_EXPRESSION_BG;
eyeColor = ACTION_EXPRESSION_EYE_COLOR;
muzzle: Muzzle = 0;
eyeLeft: Eye = 1;
eyeRight: Eye = 1;
irisLeft: Iris = 0;
irisRight: Iris = 0;
tabIndex = 0;
blush = false;
sleeping = false;
tears = false;
crying = false;
hearts = false;
activeTab = 'right-eye';
private interval: any = 0;
private subscription?: Subscription;
private actionsToUndo: ButtonActionSlot[][] = [];
constructor(private game: PonyTownGame) {
this.updateEmoteAction();
}
ngOnInit() {
document.body.classList.add('actions-modal-opened');
this.game.editingActions = true;
this.interval = setInterval(() => this.game.send(server => server.action(Action.KeepAlive)), 10000);
this.subscription = this.game.onLeft.subscribe(() => this.ok());
if (BETA) {
this.entityActions = getEntityNames().map(name => entityButtonAction(name));
}
}
ngOnDestroy() {
document.body.classList.remove('actions-modal-opened');
this.game.editingActions = false;
clearInterval(this.interval);
this.subscription && this.subscription.unsubscribe();
}
ok() {
this.close.emit();
}
changed(locked: boolean) {
if (locked) {
this.eyeLeft = this.eyeRight;
}
if (this.lockIrises) {
this.irisLeft = this.irisRight;
}
this.updateEmoteAction();
}
drop(action: ButtonAction) {
if (action.type === 'expression' && action.expression) {
const e = action.expression;
this.lockEyes = e.right === e.left;
this.lockIrises = e.rightIris === e.leftIris;
this.eyeRight = e.right;
this.eyeLeft = e.left;
this.muzzle = e.muzzle;
this.irisRight = e.rightIris;
this.irisLeft = e.leftIris;
this.blush = hasFlag(e.extra, ExpressionExtra.Blush);
this.sleeping = hasFlag(e.extra, ExpressionExtra.Zzz);
this.tears = hasFlag(e.extra, ExpressionExtra.Tears);
this.crying = hasFlag(e.extra, ExpressionExtra.Cry);
this.hearts = hasFlag(e.extra, ExpressionExtra.Hearts);
this.changed(this.lockEyes);
}
}
updateEmoteAction() {
const extra =
(this.blush ? ExpressionExtra.Blush : 0) |
(this.sleeping ? ExpressionExtra.Zzz : 0) |
(this.tears ? ExpressionExtra.Tears : 0) |
(this.crying ? ExpressionExtra.Cry : 0) |
(this.hearts ? ExpressionExtra.Hearts : 0);
const expression = createExpression(this.eyeRight, this.eyeLeft, this.muzzle, this.irisRight, this.irisLeft, extra);
this.emoteAction = expressionButtonAction(expression);
}
resetToDefault() {
this.actionsToUndo.push(this.game.actions);
this.game.actions = [...createDefaultButtonActions(), { action: undefined }];
}
clearActionBar() {
this.actionsToUndo.push(this.game.actions);
this.game.actions = this.game.actions.map(() => ({ action: undefined }));
}
undo() {
if (this.actionsToUndo.length) {
this.game.actions = this.actionsToUndo.pop()!;
}
}
updateEntity() {
if (BETA) {
this.entityAction = entityButtonAction(this.entityName);
}
}
}
@@ -0,0 +1,2 @@
.bitmap-box-row(*ngFor="let row of rows")
.bitmap-box-cell(*ngFor="let cell of row" [style.background]="colorAt(cell)" (mousedown)="draw(cell)")
@@ -0,0 +1,27 @@
@import '../../../../styles/partials/variables';
:host {
border: solid 1px $border-color;
border-radius: $border-radius-base;
overflow: hidden;
display: inline-block;
}
.bitmap-box-row {
display: flex;
&:last-child > .bitmap-box-cell {
border-bottom: none;
}
}
.bitmap-box-cell {
width: 40px;
height: 40px;
border-right: solid 1px $border-color;
border-bottom: solid 1px $border-color;
&:last-child {
border-right: none;
}
}
@@ -0,0 +1,45 @@
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { parseColor, colorToCSS } from '../../../common/color';
@Component({
selector: 'bitmap-box',
templateUrl: 'bitmap-box.pug',
styleUrls: ['bitmap-box.scss'],
})
export class BitmapBox implements OnChanges {
@Input() width = 5;
@Input() height = 5;
@Input() bitmap?: string[];
@Input() tool?: string;
@Input() color = 'red';
@Output() colorChange = new EventEmitter<string>();
rows?: number[][];
ngOnChanges(changes: SimpleChanges) {
if (changes.width || changes.height) {
this.rows = [];
for (let y = 0; y < this.height; y++) {
this.rows[y] = [];
for (let x = 0; x < this.width; x++) {
this.rows[y][x] = x + this.width * y;
}
}
}
}
draw(index: number) {
if (this.bitmap) {
if (this.tool === 'eraser') {
this.bitmap[index] = '';
} else if (this.tool === 'brush') {
this.bitmap[index] = parseColor(this.bitmap[index]) === parseColor(this.color) ? '' : this.color;
} else if (this.tool === 'eyedropper') {
this.color = this.bitmap[index];
this.colorChange.emit(this.color);
}
}
}
colorAt(index: number) {
return this.bitmap && this.bitmap[index] ? colorToCSS(parseColor(this.bitmap[index])) : '';
}
}
@@ -0,0 +1,20 @@
.form-group.text-center
label#butt-mark-label.text-muted Butt mark
.row.form-group(role="group" aria-labelledby="butt-mark-label")
.col-sm-7.mb-1
button.btn.btn-primary((click)="clearCM()" title="Clear all")
fa-icon([icon]="trashIcon" [fixedWidth]="true")
.btn-group.ml-1(btnRadioGroup [(ngModel)]="state.brushType")
button.btn.btn-primary(btnRadio="eraser" title="Eraser")
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
button.btn.btn-primary(btnRadio="eyedropper" title="Eyedropper")
fa-icon([icon]="eyeDropperIcon" [fixedWidth]="true")
button.btn.btn-primary(btnRadio="brush" title="Brush")
fa-icon([icon]="paintBrushIcon" [fixedWidth]="true")
.col-sm-5.mb-1
color-picker([(color)]="state.brush" label="Brush color")
.form-group.text-center
bitmap-box([bitmap]="info.cm" [tool]="state.brushType" [(color)]="state.brush" [width]="cmSize" [height]="cmSize")
.form-group.d-flex.justify-content-center.align-items-center.text-muted
custom-checkbox([(checked)]="info.cmFlip")
| don't flip mark on the right side
@@ -0,0 +1,30 @@
import { Component, Input } from '@angular/core';
import { fill } from 'lodash';
import { PonyInfo } from '../../../common/interfaces';
import { CM_SIZE } from '../../../common/constants';
import { faTrash, faEraser, faPaintBrush, faEyeDropper } from '../../../client/icons';
export interface ButtMarkEditorState {
brushType: string;
brush: string;
}
@Component({
selector: 'butt-mark-editor',
templateUrl: 'butt-mark-editor.pug',
})
export class ButtMarkEditor {
readonly trashIcon = faTrash;
readonly eraserIcon = faEraser;
readonly eyeDropperIcon = faEyeDropper;
readonly paintBrushIcon = faPaintBrush;
readonly cmSize = CM_SIZE;
@Input() info!: PonyInfo;
@Input() state = {
brushType: 'brush',
brush: 'orange',
};
clearCM() {
fill(this.info.cm!, '');
}
}
@@ -0,0 +1,29 @@
.sr-only(#ariaAnnounce aria-live="assertive")
.character-list([class.character-list-searchable]="searchable" [class.in-game]="inGame")
.dropdown.character-select-search.input-group(
(mousedown)="$event.stopPropagation()" (mouseup)="$event.stopPropagation()"
(click)="$event.stopPropagation()" (keydown)="keydown($event)")
input.form-control(
#searchInput type="search" [placeholder]="placeholder" [(ngModel)]="search" (input)="updatePonies()"
autocomplete="nope")
.input-group-append(*ngIf="tags.length" #tagsDropdown="ag-dropdown" dropdown)
button.btn.btn-secondary.rounded-right(dropdownToggle title="Search by tags")
fa-icon([icon]="hashIcon")
.dropdown-menu.dropdown-menu-right.shadow.tag-list(*dropdownMenu)
button.dropdown-item(*ngFor="let tag of tags" (click)="search = tag; tagsDropdown.close(); updatePonies()")
| {{tag}}
ul.character-select-list(role="listbox" [attr.aria-activedescendant]="'pony-item-' + selectedIndex")
li(
*ngFor="let p of ponies; let i = index"
[id]="'pony-item-' + i"
[class.active]="i === selectedIndex"
[class.selected]="p?.id === selectedPony?.id")
a.d-flex(role="option" (click)="select(p)" (mouseenter)="setPreview(p)" (mouseleave)="unsetPreview(p)")
span.flex-grow-1.character-name {{p.name}}
span.character-desc.text-muted {{p.desc}}
li(*ngIf="canNew")
a.text-center(role="option" (click)="createNew()")
em new pony
@@ -0,0 +1,108 @@
@import '../../../../styles/partials/variables';
.character-list {
width: 300px;
max-width: calc(100vw - 70px);
}
.character-list.in-game {
max-width: calc(100vw - 90px);
}
.character-select-search {
height: 30px;
padding: 0 5px;
display: none;
position: relative;
> .form-control {
background: white;
color: #222;
border-color: #eee !important;
border-radius: 3px !important;
box-shadow: none !important;
// width: 100%;
&:active, &:focus {
border-color: #ddd !important;
}
}
.character-list-searchable > & {
display: flex;
}
}
.hash-button {
position: absolute;
right: 7px;
top: 5px;
font-size: 16px;
padding: 0 10px;
}
.character-select-list {
background: #fff;
padding: 0;
margin: 0;
list-style: none;
font-size: $font-size-base;
text-align: left;
padding-top: 1px;
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
> li {
&.active {
background: $nav-tabs-link-active-color;
}
&.selected > a {
font-weight: bold;
}
> a {
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: $line-height-base;
color: $dropdown-link-color;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&:focus {
text-decoration: none;
color: $dropdown-link-hover-color;
background-color: $dropdown-link-hover-bg;
}
}
}
.character-list-searchable > & {
border-top: solid 1px #eee;
margin-top: 9px;
}
}
.character-desc {
font-size: smaller;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
margin-left: 10px;
}
.character-name {
white-space: nowrap;
}
.tag-list {
max-height: 400px;
overflow-y: auto;
}
@@ -0,0 +1,172 @@
import { Component, Output, EventEmitter, ViewChild, ElementRef, OnInit, Input, NgZone } from '@angular/core';
import { uniq } from 'lodash';
import { Key } from '../../../client/input/input';
import { PonyObject } from '../../../common/interfaces';
import { clamp, flatten } from '../../../common/utils';
import { isMobile } from '../../../client/data';
import { Model } from '../../services/model';
import { LATEST_CHARACTER_LIMIT } from '../../../common/constants';
import { faHashtag } from '../../../client/icons';
function getSortTag(pony: PonyObject) {
const match = pony.desc && /(?:^| )@(top|end|\d+)(?:$| )/.exec(pony.desc);
return match && match[1];
}
function sortTagToNumber(tag: string) {
if (tag === 'top') {
return -1;
} else if (tag === 'end') {
return 999999999;
} else {
return +tag;
}
}
function fallbackComparePonies(a: PonyObject, b: PonyObject) {
return a.name.localeCompare(b.name) || (a.desc || '').localeCompare(b.desc || '');
}
function comparePonies(a: PonyObject, b: PonyObject) {
const aTag = getSortTag(a);
const bTag = getSortTag(b);
if (aTag && bTag) {
return (sortTagToNumber(aTag) - sortTagToNumber(bTag)) || fallbackComparePonies(a, b);
} else if (aTag) {
return aTag === 'end' ? 1 : -1;
} else if (bTag) {
return bTag === 'end' ? -1 : 1;
} else {
return fallbackComparePonies(a, b);
}
}
@Component({
selector: 'character-list',
templateUrl: 'character-list.pug',
styleUrls: ['character-list.scss'],
})
export class CharacterList implements OnInit {
readonly hashIcon = faHashtag;
@Input() inGame = false;
@Input() canNew = false;
@Output() close = new EventEmitter<void>();
@Output() newCharacter = new EventEmitter<void>();
@Output() selectCharacter = new EventEmitter<PonyObject>();
@Output() previewCharacter = new EventEmitter<PonyObject | undefined>();
@ViewChild('ariaAnnounce', { static: true }) ariaAnnounce!: ElementRef;
@ViewChild('searchInput', { static: true }) searchInput!: ElementRef;
search?: string;
selectedIndex = -1;
ponies: PonyObject[] = [];
tags: string[] = [];
private previewPony: PonyObject | undefined = undefined;
constructor(private model: Model, private zone: NgZone) {
}
get selectedPony() {
return this.model.pony;
}
get searchable() {
return this.model.ponies.length > LATEST_CHARACTER_LIMIT;
}
get placeholder() {
return `search (${this.model.ponies.length} / ${this.model.characterLimit} ponies)`;
}
ngOnInit() {
this.updatePonies();
this.tags = uniq(flatten(this.ponies.map(p => (p.desc || '').split(/ /g).map(x => x.trim())))
.filter(x => /^#/.test(x)))
.sort();
if (!isMobile) {
setTimeout(() => this.searchInput.nativeElement.focus());
}
}
keydown(e: KeyboardEvent) {
if (e.keyCode === Key.ESCAPE) {
if (this.search) {
e.preventDefault();
e.stopPropagation();
this.search = '';
this.updatePonies();
} else {
this.closed();
}
} else if (e.keyCode === Key.ENTER) {
const pony = this.ponies[this.selectedIndex];
if (pony) {
this.select(pony);
} else {
this.closed();
}
} else if (e.keyCode === Key.UP) {
this.setSelectedIndex(this.selectedIndex <= 0 ? (this.ponies.length - 1) : (this.selectedIndex - 1));
} else if (e.keyCode === Key.DOWN) {
this.setSelectedIndex(this.selectedIndex === (this.ponies.length - 1) ? 0 : (this.selectedIndex + 1));
}
}
setPreview(pony: PonyObject) {
this.previewPony = pony;
this.previewCharacter.emit(this.model.parsePonyObject(pony));
}
unsetPreview(pony: PonyObject) {
if (this.previewPony && pony && this.previewPony.id === pony.id) {
this.previewPony = undefined;
this.previewCharacter.emit(undefined);
}
}
updatePonies() {
this.zone.run(() => {
const query = this.search && this.search.toLowerCase().trim();
function matchesWords(text: string, words: string[]) {
for (const word of words) {
if (text.indexOf(word) === -1) {
return false;
}
}
return true;
}
if (query) {
const words = query.split(/ /g).map(x => x.trim());
this.ponies = this.model.ponies.filter(pony => {
const text = `${pony.name} ${pony.desc || ''}`.toLowerCase();
return matchesWords(text, words);
}).sort(comparePonies);
} else {
this.ponies = this.model.ponies.slice().sort(comparePonies);
}
this.setSelectedIndex(this.selectedIndex);
this.previewCharacter.emit(undefined);
});
}
select(pony: PonyObject) {
this.selectCharacter.emit(pony);
}
createNew() {
this.newCharacter.emit();
}
private closed() {
this.zone.run(() => this.close.emit());
}
private setSelectedIndex(index: number) {
this.zone.run(() => {
this.selectedIndex = clamp(index, -1, this.ponies.length - 1);
const pony = this.ponies[index];
this.ariaAnnounce.nativeElement.textContent = pony ? pony.name : '';
if (pony) {
this.setPreview(pony);
} else if (this.previewPony) {
this.unsetPreview(this.previewPony);
}
});
}
}
@@ -0,0 +1,192 @@
import {
Component, Input, ElementRef, AfterViewInit, OnDestroy, NgZone, ViewChild, OnChanges, HostListener
} from '@angular/core';
import { PonyInfo, PonyState } from '../../../common/interfaces';
import { toPalette } from '../../../common/ponyInfo';
import { GRASS_COLOR, TRANSPARENT } from '../../../common/colors';
import {
createCanvas, disableImageSmoothing, getPixelRatio, resizeCanvas, resizeCanvasWithRatio
} from '../../../client/canvasUtils';
import { BLINK_FRAMES } from '../../../client/ponyUtils';
import { defaultPonyState, defaultDrawPonyOptions } from '../../../client/ponyHelpers';
import { ContextSpriteBatch } from '../../../graphics/contextSpriteBatch';
import { colorToCSS } from '../../../common/color';
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
import { drawNamePlate, commonPalettes, DrawNameFlags } from '../../../graphics/graphicsUtils';
import { drawPony } from '../../../client/ponyDraw';
import { paletteSpriteSheet } from '../../../generated/sprites';
import { replaceEmojis } from '../../../client/emoji';
const DEFAULT_STATE = defaultPonyState();
const DEFAULT_OPTIONS = defaultDrawPonyOptions();
@Component({
selector: 'character-preview',
template: '<canvas class="rounded" #canvas></canvas>',
styles: [`:host { display: block; } canvas { width: 100%; height: 100%; }`],
})
export class CharacterPreview implements OnDestroy, OnChanges, AfterViewInit {
@Input() scale = 3;
@Input() name?: string;
@Input() tag?: string;
@Input() pony?: PonyInfo;
@Input() state?: PonyState = defaultPonyState();
@Input() noBackground = false;
@Input() noOutline = false;
@Input() noShadow = false;
@Input() extra = false;
@Input() passive = false;
@Input() blinks = true;
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
private batch?: ContextSpriteBatch;
private nameBatch?: ContextSpriteBatch;
private frame = 0;
private lastFrame = 0;
private initialized = false;
private nextBlink = performance.now() + 2000;
private blinkFrame = -1;
constructor(private zone: NgZone) {
}
ngAfterViewInit() {
return loadAndInitSpriteSheets()
.then(() => this.initialized = true)
.then(() => this.ngOnChanges());
}
ngOnDestroy() {
cancelAnimationFrame(this.frame);
}
ngOnChanges() {
if (!this.frame) {
this.zone.runOutsideAngular(() => this.frame = requestAnimationFrame(this.onFrame));
}
}
@HostListener('window:resize')
redraw() {
this.tryDraw();
}
blink() {
this.nextBlink = performance.now();
}
private onFrame = () => {
if (this.passive && this.initialized) {
this.frame = 0;
this.tryDraw();
return;
}
this.frame = requestAnimationFrame(this.onFrame);
const now = performance.now();
if ((now - this.lastFrame) > (1000 / 24)) {
if (this.blinks) {
if (this.blinkFrame === -1) {
if (this.nextBlink < now) {
this.blinkFrame = 0;
}
} else {
this.blinkFrame++;
if (this.blinkFrame >= BLINK_FRAMES.length) {
this.nextBlink = now + Math.random() * 2000 + 3000;
this.blinkFrame = -1;
}
}
if (this.state) {
this.state.blinkFrame = this.blinkFrame === -1 ? 1 : BLINK_FRAMES[this.blinkFrame];
}
}
this.lastFrame = now;
this.tryDraw();
}
}
private tryDraw() {
try {
this.draw();
} catch { }
}
private draw() {
if (!this.initialized)
return;
const canvas = this.canvas.nativeElement as HTMLCanvasElement;
const { width, height } = canvas.getBoundingClientRect();
resizeCanvasWithRatio(canvas, width, height, false);
const scale = this.scale * getPixelRatio();
const bufferWidth = Math.round(canvas.width / scale);
const bufferHeight = Math.round(canvas.height / scale);
if (!bufferWidth || !bufferHeight)
return;
this.batch = this.batch || new ContextSpriteBatch(createCanvas(bufferWidth, bufferHeight));
resizeCanvas(this.batch.canvas, bufferWidth, bufferHeight);
const x = Math.round(bufferWidth / 2);
const y = Math.round(bufferHeight / 2 + 28);
if (this.pony) {
this.batch.start(paletteSpriteSheet, this.noBackground ? TRANSPARENT : GRASS_COLOR);
try {
const options = { ...DEFAULT_OPTIONS, shadow: !this.noShadow, extra: !!this.extra };
drawPony(this.batch, toPalette(this.pony), this.state || DEFAULT_STATE, x, y, options);
} catch (e) {
console.error(e);
}
this.batch.end();
}
const viewContext = canvas.getContext('2d');
if (!viewContext)
return;
disableImageSmoothing(viewContext);
if (this.noBackground) {
viewContext.clearRect(0, 0, canvas.width, canvas.height);
}
viewContext.save();
viewContext.scale(scale, scale);
// draw outline
if (this.pony && this.noShadow && this.noBackground && !this.noOutline) {
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
viewContext.drawImage(this.batch.canvas, x, y);
}
}
viewContext.globalCompositeOperation = 'source-in';
viewContext.fillStyle = colorToCSS(GRASS_COLOR);
viewContext.fillRect(0, 0, viewContext.canvas.width, viewContext.canvas.height);
viewContext.globalCompositeOperation = 'source-over';
}
viewContext.drawImage(this.batch.canvas, 0, 0);
viewContext.restore();
// draw name plate
if (!this.noShadow && this.name) {
const name = replaceEmojis(this.name);
const scale = 2 * getPixelRatio();
const nameBufferWidth = Math.round(canvas.width / scale);
this.nameBatch = this.nameBatch || new ContextSpriteBatch(createCanvas(nameBufferWidth, 25));
resizeCanvas(this.nameBatch.canvas, nameBufferWidth, 25);
this.nameBatch.start(paletteSpriteSheet, TRANSPARENT);
drawNamePlate(this.nameBatch, name, nameBufferWidth / 2, 11, DrawNameFlags.None, commonPalettes, this.tag);
this.nameBatch.end();
viewContext.save();
viewContext.scale(scale, scale);
viewContext.drawImage(this.nameBatch.canvas, 0, 10);
viewContext.restore();
}
}
}
@@ -0,0 +1,36 @@
.sr-only(#ariaAnnounce aria-live="assertive")
.character-select.input-group
input.form-control.text-center(
#nameInput
type="text"
[(ngModel)]="pony.name"
[maxlength]="maxNameLength"
placeholder="Name of your character"
aria-label="Name of your character")
.input-group-append
button.btn.btn-default(*ngIf="newButton" (click)="createNew()" [disabled]="!canNew" aria-label="Create New character")
| new
button.btn.btn-default(*ngIf="editButton" (click)="edit()" [disabled]="!canEdit" aria-label="Edit character")
| edit
.dropdown(dropdown #dropdown="ag-dropdown" (isOpenChange)="onToggle($event)" [focusOnOpen]="false")
button.btn.btn-default.dropdown-toggle.br-0(
[ngClass]="removeButton ? 'btn-no-round' : 'btn-no-round-left'"
[disabled]="!hasPonies || joining" dropdownToggle aria-label="Select character")
character-list.dropdown-menu(
*dropdownMenu (close)="dropdown.close()" (selectCharacter)="select($event)" (newCharacter)="createNew()"
(previewCharacter)="preview.emit($event)" [canNew]="!newButton && canNew")
button.btn.btn-danger.remove-button(
*ngIf="removeButton && !removing" (click)="remove()" [disabled]="!canRemove"
tooltip="Delete pony" aria-label="Delete pony")
fa-icon([icon]="deleteIcon" [fixedWidth]="true")
button.btn.btn-danger.cancel-remove-button(
*ngIf="removing" (click)="cancelRemove()"
tooltip="Cancel delete" aria-label="Cancel delete")
fa-icon([icon]="removeIcon" [fixedWidth]="true")
button.btn.btn-success(
*ngIf="removing" (click)="confirmRemove()" [disabled]="!canRemove"
tooltip="Confirm delete" aria-label="Confirm delete")
fa-icon([icon]="confirmIcon" [fixedWidth]="true")
@@ -0,0 +1,14 @@
@import '../../../../styles/partials/variables';
.character-select {
max-width: 400px;
margin: auto;
z-index: 200;
}
character-list {
@include media-breakpoint-down(sm) {
left: auto;
right: 0;
}
}
@@ -0,0 +1,128 @@
import { Component, Input, EventEmitter, Output, ViewChild, ElementRef } from '@angular/core';
import { Router } from '@angular/router';
import { PonyObject } from '../../../common/interfaces';
import { PLAYER_NAME_MAX_LENGTH } from '../../../common/constants';
import { VERSION_ERROR } from '../../../common/errors';
import { GameService } from '../../services/gameService';
import { Model, createDefaultPonyObject } from '../../services/model';
import { Dropdown } from '../directives/dropdown';
import { faSpinner, faTrash, faTimes, faCheck } from '../../../client/icons';
import { focusElementAfterTimeout } from '../../../client/htmlUtils';
import { delay } from '../../../common/utils';
import { isMobile } from '../../../client/data';
@Component({
selector: 'character-select',
templateUrl: 'character-select.pug',
styleUrls: ['character-select.scss'],
})
export class CharacterSelect {
readonly maxNameLength = PLAYER_NAME_MAX_LENGTH;
readonly spinnerIcon = faSpinner;
readonly deleteIcon = faTrash;
readonly removeIcon = faTimes;
readonly confirmIcon = faCheck;
@Input() newButton = false;
@Input() editButton = false;
@Input() removeButton = false;
@Input() error?: string;
@Output() errorChange = new EventEmitter<string | undefined>();
@Output() change = new EventEmitter<PonyObject>();
@Output() preview = new EventEmitter<PonyObject | undefined>();
@ViewChild('nameInput', { static: true }) nameInput!: ElementRef;
@ViewChild('ariaAnnounce', { static: true }) ariaAnnounce!: ElementRef;
@ViewChild('dropdown', { static: true }) dropdown!: Dropdown;
removing = false;
private locked = false; // TEMP: move to model
constructor(
private element: ElementRef,
private router: Router,
private model: Model,
private gameService: GameService,
) {
}
get joining() {
return this.gameService.joining;
}
get pony() {
return this.model.pony;
}
get canNew() {
return !this.joining && this.model.account && this.model.account.characterCount < this.model.characterLimit;
}
get canEdit() {
return !this.joining;
}
get canRemove() {
return !this.joining && !this.locked && !this.model.pending && !!this.pony
&& !!this.pony.id && this.error !== VERSION_ERROR;
}
get hasPonies() {
return !!this.model.ponies.length;
}
select(pony: PonyObject) {
if (pony) {
this.removing = false;
this.model.selectPony(pony);
this.change.emit(pony);
this.preview.emit(undefined);
}
this.dropdown.close();
this.focusName();
}
createNew() {
if (this.canNew) {
this.removing = false;
this.model.selectPony(createDefaultPonyObject());
this.change.emit(this.pony);
this.router.navigate(['/character']);
this.focusName();
}
}
edit() {
if (this.canEdit) {
this.removing = false;
this.router.navigate(['/character']);
}
}
remove() {
if (this.canRemove) {
this.removing = true;
focusElementAfterTimeout(this.element.nativeElement, '.cancel-remove-button');
}
}
cancelRemove() {
this.removing = false;
focusElementAfterTimeout(this.element.nativeElement, '.remove-button');
}
confirmRemove() {
if (this.canRemove) {
this.setError(undefined);
this.removing = false;
this.locked = true;
this.model.removePony(this.pony)
.then(() => this.change.emit(this.pony))
.catch((e: Error) => this.setError(e.message))
.then(() => this.ariaAnnounce.nativeElement.textContent = 'Character removed')
.then(() => delay(2000))
.then(() => this.locked = false)
.then(() => this.focusName());
}
}
onToggle(show: boolean) {
if (!show) {
this.preview.emit(undefined);
}
}
private focusName() {
if (!isMobile) {
this.nameInput.nativeElement.focus();
}
}
private setError(error: string | undefined) {
this.error = error;
this.errorChange.emit(error);
}
}
@@ -0,0 +1,14 @@
.chat-box(#chatBox)
div(#chatBoxInput)
input.chat-input#chat-input(
#inputElement [(ngModel)]="message" (keydown)="keydown($event)" [maxlength]="maxSayLength"
aria-label="Chat message")
.chat-box-type(#typeBox (click)="toggleChatType()")
| #[span(#typePrefix)][#[span.chat-box-type-name(#typeName)]]:
button.game-button.chat-send-button((click)="send($event)" title="Send message" aria-label="Send message")
fa-icon([icon]="sendIcon")
button.game-button.chat-open-button(
(click)="toggle()" (mousedown)="$event.preventDefault()" [disabled]="disabled" title="Toggle chat"
aria-label="Toggle message")
fa-icon([icon]="commentIcon" [fixedWidth]="true")
@@ -0,0 +1,88 @@
@import '../../../../styles/partials/variables';
.chat-box {
max-width: 500px;
height: 37px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.chat-open-button {
display: block;
position: absolute;
left: 0;
bottom: 0;
z-index: 2;
width: 42px;
height: 40px;
}
.chat-send-button {
position: absolute;
right: 0;
top: 0;
padding: 0 8px;
}
.chat-input {
opacity: 0.6;
background: black;
color: white;
border: none;
z-index: 1;
padding: 8px;
font-size: 14px;
padding-right: 35px;
padding-left: 90px;
border-radius: $border-radius-base;
outline: none;
width: 100%;
}
.chat-box-type {
position: absolute;
left: 42px;
top: 8px;
font-size: 14px;
user-select: none;
cursor: pointer;
.chat-party > &, .chat-party-think > & {
color: $chat-party;
}
.chat-say > &, .chat-think > & {
color: $chat-color;
}
.chat-sup > & {
color: $supporter-1;
}
.chat-sup1 > & {
color: $supporter-1;
}
.chat-sup2 > & {
color: $supporter-2;
}
.chat-sup3 > & {
color: $supporter-3;
}
.chat-whisper > & {
color: $chat-whisper;
}
}
.chat-box-type-name {
display: inline-block;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
@@ -0,0 +1,388 @@
import { Component, ElementRef, NgZone, AfterViewInit, ViewChild, OnDestroy, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { ChatType, isPartyChat, Entity, FakeEntity } from '../../../common/interfaces';
import { SAY_MAX_LENGTH } from '../../../common/constants';
import { Key } from '../../../client/input/input';
import { PonyTownGame } from '../../../client/game';
import { cleanMessage, isSpamMessage } from '../../../client/clientUtils';
import { faComment, faAngleDoubleRight } from '../../../client/icons';
import { isInParty } from '../../../client/partyUtils';
import { handleActionCommand } from '../../../client/playerActions';
import { hasHeadAnimation } from '../../../common/pony';
import { AutocompleteState, autocompleteMesssage, replaceEmojis } from '../../../client/emoji';
import { replaceNodes } from '../../../client/htmlUtils';
import { invalidEnumReturn } from '../../../common/utils';
import { findMatchingEntityNames, findEntityOrMockByAnyMeans, findBestEntityByName } from '../../../client/handlers';
const chatTypeNames: string[] = [];
const chatTypeClasses: string[] = [];
function setupChatType(type: ChatType, name: string) {
chatTypeNames[type] = name;
chatTypeClasses[type] = `chat-${name.replace(/ /, '-')}`;
}
setupChatType(ChatType.Say, 'say');
setupChatType(ChatType.Party, 'party');
setupChatType(ChatType.Supporter, 'sup');
setupChatType(ChatType.Supporter1, 'sup1');
setupChatType(ChatType.Supporter2, 'sup2');
setupChatType(ChatType.Supporter3, 'sup3');
setupChatType(ChatType.Whisper, 'whisper');
setupChatType(ChatType.Think, 'think');
setupChatType(ChatType.PartyThink, 'party think');
function isActionCommand(message: string) {
return /^\/(yawn|sneeze|achoo|laugh|lol|haha|хаха|jaja)/i.test(message);
}
@Component({
selector: 'chat-box',
templateUrl: 'chat-box.pug',
styleUrls: ['chat-box.scss'],
})
export class ChatBox implements AfterViewInit, OnDestroy {
readonly maxSayLength = SAY_MAX_LENGTH;
readonly commentIcon = faComment;
readonly sendIcon = faAngleDoubleRight;
@ViewChild('inputElement', { static: true }) inputElement!: ElementRef;
@ViewChild('typeBox', { static: true }) typeBox!: ElementRef;
@ViewChild('typePrefix', { static: true }) typePrefix!: ElementRef;
@ViewChild('typeName', { static: true }) typeName!: ElementRef;
@ViewChild('chatBox', { static: true }) chatBox!: ElementRef;
@ViewChild('chatBoxInput', { static: true }) chatBoxInput!: ElementRef;
isOpen = false;
message: string | undefined = '';
chatType = ChatType.Say;
private pasted = false;
private lastMessages: string[] = [];
private state: AutocompleteState = {};
private subscriptions: Subscription[];
private _disabled = false;
constructor(private game: PonyTownGame, zone: NgZone) {
this.subscriptions = [
this.game.onChat.subscribe(() => zone.run(() => this.chat(undefined))),
this.game.onToggleChat.subscribe(() => zone.run(() => this.toggle())),
this.game.onCommand.subscribe(() => zone.run(() => this.command())),
this.game.onLeft.subscribe(() => {
this.chatType = ChatType.Say;
this.close();
}),
];
this.game.onCancel = () => this.isOpen ? (zone.run(() => this.close()), true) : false;
}
@Input() get disabled() {
return this._disabled;
}
set disabled(value) {
this._disabled = value;
if (value) {
this.close();
}
}
get input() {
return this.inputElement.nativeElement as HTMLInputElement;
}
ngAfterViewInit() {
this.chatBox.nativeElement.hidden = true;
this.input.addEventListener('paste', () => this.pasted = true);
}
ngOnDestroy() {
this.subscriptions.forEach(s => s.unsubscribe());
}
send(_event: Event | undefined) {
let chatType = this.chatType;
let message = replaceEmojis(cleanMessage(this.message || '')).substr(0, SAY_MAX_LENGTH);
const handled = handleActionCommand(message, this.game);
const spam = this.pasted && chatType !== ChatType.Party && isSpamMessage(message, this.lastMessages);
const empty = !this.game.player || !message;
const ignoreAction = isActionCommand(message) && this.game.player && hasHeadAnimation(this.game.player);
const whisperTo = this.game.whisperTo;
let entityId = whisperTo && whisperTo.id || 0;
if (/^\/(w|whisper) .+$/i.test(message)) {
chatType = ChatType.Whisper;
message = message.substr(/^\/w /i.test(message) ? 3 : 9);
let offset = 0;
let entity: Entity | FakeEntity | undefined = undefined;
do {
offset = message.indexOf(' ', offset);
if (offset === -1)
break;
const name = message.substr(0, offset);
entity = findBestEntityByName(this.game, name);
offset++;
} while (!entity);
if (entity) {
message = message.substr(offset);
entityId = entity.id;
} else {
entityId = 0;
}
}
if (handled || spam || empty || ignoreAction || this.say(message, chatType, entityId)) {
if (message) {
this.lastMessages.push(message);
while (this.lastMessages.length > 5) {
this.lastMessages.shift();
}
}
this.close();
}
}
keydown(e: KeyboardEvent) {
if (e.keyCode !== Key.TAB && e.keyCode !== Key.SHIFT) {
this.state.lastEmoji = undefined;
}
if (e.keyCode === Key.TAB) {
if (this.message) {
if (/^\/(w|whisper) .+$/i.test(this.message)) {
const space = this.message.indexOf(' ');
const names = findMatchingEntityNames(this.game, this.message.substr(space + 1));
if (names.length === 1) {
this.message = `${this.message.substring(0, space)} ${names[0]}`;
}
} else {
this.message = autocompleteMesssage(this.message, e.shiftKey, this.state);
}
}
e.preventDefault();
} else if (e.keyCode === Key.ENTER && this.isOpen) {
this.send(e);
} else if (e.keyCode === Key.ESCAPE) {
this.close();
e.preventDefault();
} else if (e.keyCode === Key.SPACE) {
if (!this.message)
return;
const isParty = /^\/(p|party)$/i.test(this.message);
const isSay = /^\/(s|say)$/i.test(this.message);
const isSup = /^\/(ss)$/i.test(this.message);
const isSup1 = /^\/(s1)$/i.test(this.message);
const isSup2 = /^\/(s2)$/i.test(this.message);
const isSup3 = /^\/(s3)$/i.test(this.message);
const supporter = this.game.model.supporter;
const isSayOrInvalid = isSay
|| (isParty && !isInParty(this.game))
|| (isSup && supporter === 0)
|| (isSup1 && supporter < 1)
|| (isSup2 && supporter < 2)
|| (isSup3 && supporter < 3);
if (isSayOrInvalid) {
this.changeChatType(e, ChatType.Say);
} else if (isParty) {
this.changeChatType(e, ChatType.Party);
} else if (isSup) {
this.changeChatType(e, ChatType.Supporter);
} else if (isSup1) {
this.changeChatType(e, ChatType.Supporter1);
} else if (isSup2) {
this.changeChatType(e, ChatType.Supporter2);
} else if (isSup3) {
this.changeChatType(e, ChatType.Supporter3);
} else if (/^\/(t|think)$/i.test(this.message)) {
if (isPartyChat(this.chatType)) {
this.changeChatType(e, ChatType.PartyThink);
} else {
this.changeChatType(e, ChatType.Think);
}
} else if (/^\/(r|reply)$/i.test(this.message)) {
const lastWhisperFrom = this.game.lastWhisperFrom;
const entity = lastWhisperFrom && findEntityOrMockByAnyMeans(this.game, lastWhisperFrom.entityId);
if (entity) {
this.game.whisperTo = entity;
this.changeChatType(e, ChatType.Whisper);
} else {
this.changeChatType(e, ChatType.Say);
}
} else if (/^\/(w|whisper) .+$/i.test(this.message) && !e.shiftKey) {
const name = this.message.substr(/^\/w /i.test(this.message) ? 3 : 9);
const entity = findBestEntityByName(this.game, name);
if (entity) {
this.game.whisperTo = entity;
this.changeChatType(e, ChatType.Whisper);
}
}
}
}
private say(message: string, chatType: ChatType, entityId: number): boolean {
this.game.lastChatMessageType = chatType;
return !!this.game.send(server => server.say(entityId, message, chatType));
}
private changeChatType(e: KeyboardEvent, chatType: ChatType) {
this.chatType = chatType;
this.message = '';
this.updateChatType();
e.preventDefault();
}
private chat(event: Event | undefined) {
if (this.isOpen) {
this.send(event);
} else {
this.open();
}
}
private command() {
if (!this.isOpen) {
this.chat(undefined);
this.message = '/';
this.input.selectionStart = this.input.selectionEnd = 10000;
}
}
private open() {
if (!this.isOpen) {
this.isOpen = true;
this.chatBox.nativeElement.hidden = false;
}
this.chatType = isValidChatType(this.chatType, this.game) ? this.chatType : ChatType.Say;
this.updateChatType();
this.input.focus();
}
private close() {
if (this.isOpen) {
this.input.blur();
this.isOpen = false;
this.chatBox.nativeElement.hidden = true;
this.message = '';
this.pasted = false;
}
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
toggleChatType() {
const chatTypes = getChatTypes(this.game);
this.chatType = chatTypes[(chatTypes.indexOf(this.chatType) + 1) % chatTypes.length];
this.updateChatType();
this.input.focus();
}
setChatType(type: 'say' | 'party' | 'whisper') {
if (type === 'say') {
this.chatType = ChatType.Say;
this.open();
} else if (type === 'party' && isInParty(this.game)) {
this.chatType = ChatType.Party;
this.open();
} else if (type === 'whisper') {
this.chatType = ChatType.Whisper;
this.open();
}
}
private currentTypeClass = '';
private currentTypePrefix = '';
private currentTypeName = '';
private updateChatType() {
let typeName: string;
let typePrefix: string;
let changed = false;
const typeClass = chatTypeClass(this.chatType, this.game.model.supporter);
if (this.currentTypeClass !== typeClass) {
this.currentTypeClass = typeClass;
(this.chatBoxInput.nativeElement as HTMLElement).className = typeClass;
}
if (this.chatType === ChatType.Whisper) {
typePrefix = 'To ';
typeName = this.game.whisperTo && this.game.whisperTo.name || 'unknown';
} else {
typePrefix = '';
typeName = chatTypeNames[this.chatType];
}
if (this.currentTypePrefix !== typePrefix) {
changed = true;
this.currentTypePrefix = typePrefix;
(this.typePrefix.nativeElement as HTMLElement).textContent = typePrefix;
}
if (this.currentTypeName !== typeName) {
changed = true;
this.currentTypeName = typeName;
replaceNodes(this.typeName.nativeElement, typeName);
}
if (changed) {
const { width } = (this.typeBox.nativeElement as HTMLElement).getBoundingClientRect();
const padding = 35 + 13 + Math.ceil(width);
(this.inputElement.nativeElement as HTMLElement).style.paddingLeft = `${padding}px`;
}
}
}
function chatTypeClass(chatType: ChatType, supporter: number) {
if (chatType === ChatType.Supporter) {
switch (supporter) {
case 1: return 'chat-sup chat-sup1';
case 2: return 'chat-sup chat-sup2';
case 3: return 'chat-sup chat-sup3';
}
}
return chatTypeClasses[chatType];
}
function isValidChatType(type: ChatType, game: PonyTownGame) {
const supporter = game.model.supporter;
switch (type) {
case ChatType.Say:
case ChatType.Think:
case ChatType.Whisper:
return true;
case ChatType.Party:
case ChatType.PartyThink:
return isInParty(game);
case ChatType.Supporter:
return supporter > 0;
case ChatType.Supporter1:
return supporter >= 1;
case ChatType.Supporter2:
return supporter >= 2;
case ChatType.Supporter3:
return supporter >= 3;
case ChatType.Dismiss:
return false;
default:
return invalidEnumReturn(type, false);
}
}
function getChatTypes(game: PonyTownGame) {
const chatTypes = [ChatType.Say];
const supporter = game.model.supporter;
if (isInParty(game)) {
chatTypes.push(ChatType.Party);
}
if (supporter) {
chatTypes.push(ChatType.Supporter);
}
return chatTypes;
}
@@ -0,0 +1,27 @@
.chat-log(#chatLog)
.d-flex.chat-log-buttons
.flex-grow-1.d-flex.flex-column-reverse
button.game-button.mb-2((click)="scrollToEnd()" title="Scroll to end")
fa-icon([icon]="toBottomIcon" [fixedWidth]="true")
.chat-log-content(#content)
fa-icon.resize-icon([icon]="resizeIcon")
.chat-log-tabs
a.link-plain.chat-log-tab(#localTab tabindex (click)="switchTab('local')")
| Local
a.link-plain.chat-log-tab(#partyTab tabindex (click)="switchTab('party')")
| Party
a.link-plain.chat-log-tab(#whisperTab tabindex (click)="switchTab('whisper')")
| Whisper
.chat-log-scroll-outer()
.chat-log-scroll-inner(#scroll)
.chat-log-scroll-inner-inner(#lines)
.chat-log-resize-top((agDrag)="drag($event, true, false)")
.chat-log-resize-right((agDrag)="drag($event, false, true)")
.chat-log-resize-top-right((agDrag)="drag($event, true, true)")
button.game-button.chat-log-toggle(#toggleButton (click)="toggle()" title="Toggle chatlog")
svg.fa-icon(aria-hidden="true" class="svg-inline--fa fa-comments fa-w-18 fa-fw" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512")
path.main-bubble(fill="currentColor" d="M416 192c0-88.4-93.1-160-208-160S0 103.6 0 192c0 34.3 14.1 65.9 38 92-13.4 30.2-35.5 54.2-35.8 54.5-2.2 2.3-2.8 5.7-1.5 8.7S4.8 352 8 352c36.6 0 66.9-12.3 88.7-25 32.2 15.7 70.3 25 111.3 25 114.9 0 208-71.6 208-160z")
path(fill="currentColor" d="M538 412c23.9-26 38-57.7 38-92 0-66.9-53.5-124.2-129.3-148.1.9 6.6 1.3 13.3 1.3 20.1 0 105.9-107.7 192-240 192-10.8 0-21.3-.8-31.7-1.9C207.8 439.6 281.8 480 368 480c41 0 79.1-9.2 111.3-25 21.8 12.7 52.1 25 88.7 25 3.2 0 6.1-1.9 7.3-4.8 1.3-2.9.7-6.3-1.5-8.7-.3-.3-22.4-24.2-35.8-54.5z")
.whispers-count(#count)
@@ -0,0 +1,159 @@
@import '../../../../styles/partials/variables';
$chat-log-bg: rgba(0, 0, 0, 0.2);
$chat-log-bg-faded: rgba(0, 0, 0, 0.1);
$chat-log-bg-hover: rgba(0, 0, 0, 0.5);
$chat-log-tabs-height: 30px;
$chat-log-buttons-width: 40px;
$resizer-size: 12px;
$resizer-offset: $resizer-size / 2;
.chat-log {
display: flex;
position: absolute;
bottom: 0;
left: 0;
right: 0;
min-width: 200px;
max-width: 100%;
max-height: calc(100vh - 220px);
min-height: 120px;
}
.chat-log-buttons {
flex-direction: column-reverse;
width: $chat-log-buttons-width;
padding: 5px 3px;
padding-bottom: 30px;
z-index: 2;
}
.chat-log-toggle {
position: absolute;
bottom: 5px;
left: 4px;
z-index: 3;
}
.has-unread .main-bubble {
color: $chat-whisper;
}
.whispers-count {
color: white;
font-weight: bold;
text-align: center;
position: absolute;
left: 6px;
top: 9px;
width: 16px;
font-size: 10px;
display: flex;
justify-content: center;
align-items: center;
line-height: 1rem;
}
.whisper-icon {
position: absolute;
top: 0;
left: 0;
font-size: 14px;
color: $chat-whisper !important;
}
.chat-log-content {
position: relative;
display: flex;
background: $chat-log-bg;
padding: 5px 0;
padding-left: 40px;
margin-left: -$chat-log-buttons-width;
border-radius: $border-radius-base;
word-break: break-word;
white-space: pre-line;
line-height: 20px;
height: 100%;
width: 100%;
}
.chat-log-tabs {
position: absolute;
top: -$chat-log-tabs-height;
left: 30px;
display: flex;
user-select: none;
}
.chat-log-tab {
background: $chat-log-bg-faded;
padding: 5px 10px 0;
margin-left: 10px;
height: $chat-log-tabs-height;
border-top-left-radius: $border-radius-base;
border-top-right-radius: $border-radius-base;
color: white;
overflow: hidden;
&.active {
background: $chat-log-bg;
}
}
.chat-log-scroll-outer {
overflow: hidden;
width: 100%;
}
.chat-log-scroll-inner {
width: calc(100% + 50px);
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
.chat-log-scroll-inner-inner {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
padding: 0 5px;
}
.chat-log-resize-top {
position: absolute;
top: -$resizer-offset;
right: $resizer-offset;
height: $resizer-size;
left: 250px;
cursor: ns-resize;
}
.chat-log-resize-top-right {
position: absolute;
top: -($resizer-offset + 1);
right: -($resizer-offset + 1);
width: $resizer-size + 4;
height: $resizer-size + 4;
cursor: nesw-resize;
}
.chat-log-resize-right {
position: absolute;
top: $resizer-offset;
right: -$resizer-offset;
width: $resizer-size;
bottom: $resizer-offset;
cursor: ew-resize;
}
.resize-icon {
font-size: 18px;
position: absolute;
top: -3px;
right: 1px;
transform: rotate(45deg);
color: rgba(255, 255, 255, 0.2);
}
@@ -0,0 +1,620 @@
import {
Component, ViewChild, ElementRef, NgZone, AfterViewInit, OnDestroy, HostListener, Output, EventEmitter, DoCheck
} from '@angular/core';
import { Subscription } from 'rxjs';
import { clamp, escapeRegExp } from 'lodash';
import { PonyTownGame } from '../../../client/game';
import { MessageType, isPartyMessage, ChatMessage, Pony, FakeEntity, isWhisper, isWhisperTo } from '../../../common/interfaces';
import { SettingsService } from '../../services/settingsService';
import { AgDragEvent } from '../directives/agDrag';
import { element, textNode, removeAllNodes, replaceNodes } from '../../../client/htmlUtils';
import { DEFAULT_CHATLOG_OPACITY, PONY_TYPE } from '../../../common/constants';
import { faCaretUp, faArrowDown } from '../../../client/icons';
import { sampleMessages } from '../../../common/debugData';
interface IndexEntryUser {
id: number;
crc: number | undefined;
}
interface IndexEntry {
users: IndexEntryUser[];
counter: number;
}
interface ChatLogLineDOM {
entry: ChatLogMessage;
root: HTMLElement;
label: HTMLElement;
labelText: Text;
name: HTMLElement;
nameContent: HTMLElement;
index: HTMLElement;
indexText: Text;
prefixText: Text;
suffixText: Text;
message: HTMLElement;
}
export interface ChatLogMessage {
message: string;
name?: string;
crc?: number;
prefix?: string;
suffix?: string;
label?: string;
index: number;
classes?: string;
entityId?: number;
dom?: ChatLogLineDOM;
}
type Tab = 'local' | 'party' | 'whisper';
type ClickHandler = (entry: ChatLogMessage) => void;
const GENERAL_CHAT_LIMIT = 100;
const PARTY_CHAT_LIMIT = 100;
const WHISPER_CHAT_LIMIT = 100;
const FORGET_INDEX_AFTER = 1000;
const SCROLL_END_THRESHOLD = 60;
const LABELS: (string | undefined)[] = [];
LABELS[MessageType.System] = 'system';
LABELS[MessageType.Admin] = 'admin';
LABELS[MessageType.Mod] = 'mod';
LABELS[MessageType.Party] = 'party';
LABELS[MessageType.PartyThinking] = 'party';
LABELS[MessageType.PartyAnnouncement] = 'party';
const PREFIXES: (string | undefined)[] = [];
PREFIXES[MessageType.WhisperTo] = 'To ';
PREFIXES[MessageType.WhisperToAnnouncement] = 'To ';
const SUFFIXES: (string | undefined)[] = [];
SUFFIXES[MessageType.Thinking] = 'thinks';
SUFFIXES[MessageType.PartyThinking] = 'thinks';
SUFFIXES[MessageType.Whisper] = 'whispers';
SUFFIXES[MessageType.WhisperAnnouncement] = 'whispers';
const CLASSES: (string | undefined)[] = [];
CLASSES[MessageType.System] = 'chat-line-system';
CLASSES[MessageType.Admin] = 'chat-line-admin';
CLASSES[MessageType.Mod] = 'chat-line-mod';
CLASSES[MessageType.Party] = 'chat-line-party';
CLASSES[MessageType.Thinking] = 'chat-line-thinking';
CLASSES[MessageType.PartyThinking] = 'chat-line-party-thinking';
CLASSES[MessageType.Announcement] = 'chat-line-announcement';
CLASSES[MessageType.PartyAnnouncement] = 'chat-line-party-announcement';
CLASSES[MessageType.Supporter1] = 'chat-line-supporter-1';
CLASSES[MessageType.Supporter2] = 'chat-line-supporter-2';
CLASSES[MessageType.Supporter3] = 'chat-line-supporter-3';
CLASSES[MessageType.Whisper] = 'chat-line-whisper';
CLASSES[MessageType.WhisperTo] = 'chat-line-whisper';
CLASSES[MessageType.WhisperAnnouncement] = 'chat-line-whisper-announcement';
CLASSES[MessageType.WhisperToAnnouncement] = 'chat-line-whisper-announcement';
export function createChatLogLineDOM(clickLabel: ClickHandler, clickName: ClickHandler): ChatLogLineDOM {
const line: ChatLogLineDOM = {} as any;
line.root = element('div', 'chat-line', [
element('span', 'chat-line-lead'),
line.label = element(
'span', 'chat-line-label mr-1', [line.labelText = textNode('')], undefined, { click: () => clickLabel(line.entry) }),
line.prefixText = textNode(''),
line.name = element('span', 'chat-line-name', [
textNode('['),
line.nameContent = element(
'span', 'chat-line-name-content', [textNode('')], undefined, { click: () => clickName(line.entry) }),
line.index = element('span', 'chat-line-name-index', [line.indexText = textNode('')], { title: 'duplicate name' }),
textNode(']'),
]),
line.suffixText = textNode(''),
line.message = element('span', 'chat-line-message', [textNode('')]),
]);
return line;
}
export function updateChatLogLine(line: ChatLogLineDOM, entry: ChatLogMessage) {
const { classes, label, message, prefix, suffix } = entry;
const hasSpace = message.indexOf(' ') !== -1;
line.entry = entry;
line.root.className = `chat-line ${hasSpace ? '' : 'chat-line-break '}${classes}`.trim();
line.label.style.display = label ? 'inline' : 'none';
line.labelText.nodeValue = label ? `[${label}]` : '';
updateChatLogName(line, entry);
line.prefixText.nodeValue = prefix || '';
line.suffixText.nodeValue = suffix ? ` ${suffix}: ` : ': ';
replaceNodes(line.message, message);
}
function updateChatLogName(line: ChatLogLineDOM, { name, index }: ChatLogMessage) {
if (name) {
line.name.style.display = 'inline';
replaceNodes(line.nameContent, name);
line.index.style.display = (index > 0) ? 'inline' : 'none';
line.indexText.nodeValue = (index > 0) ? ` #${index + 1}` : '';
} else {
line.name.style.display = 'none';
}
}
function isMatch(e: ChatLogMessage, id: number, name: string, crc: number | undefined) {
return e.name === name && (e.entityId === id || (e.crc === crc && crc !== undefined));
}
function addOrUpdatePony(pony: Pony, list: ChatLogMessage[]) {
for (const e of list) {
if (isMatch(e, pony.id, pony.name!, pony.crc)) {
e.entityId = pony.id;
}
}
}
function updateEntityId(list: ChatLogMessage[], oldId: number, newId: number) {
for (const e of list) {
if (e.entityId === oldId) {
e.entityId = newId;
}
}
}
function findUserIndex(users: IndexEntryUser[], id: number, crc: number | undefined) {
for (let i = 0; i < users.length; i++) {
const user = users[i];
if (user.id === id || (crc !== undefined && user.crc === crc)) {
user.id = id;
return i;
}
}
return -1;
}
@Component({
selector: 'chat-log',
templateUrl: 'chat-log.pug',
styleUrls: ['chat-log.scss'],
})
export class ChatLog implements AfterViewInit, OnDestroy, DoCheck {
readonly toBottomIcon = faArrowDown;
readonly resizeIcon = faCaretUp;
@ViewChild('chatLog', { static: true }) chatLog!: ElementRef;
@ViewChild('scroll', { static: true }) scroll!: ElementRef;
@ViewChild('lines', { static: true }) lines!: ElementRef;
@ViewChild('localTab', { static: true }) localTab!: ElementRef;
@ViewChild('partyTab', { static: true }) partyTab!: ElementRef;
@ViewChild('whisperTab', { static: true }) whisperTab!: ElementRef;
@ViewChild('toggleButton', { static: true }) toggleButton!: ElementRef;
@ViewChild('count', { static: true }) countElement!: ElementRef;
@ViewChild('content', { static: true }) contentElement!: ElementRef;
@Output() toggleType = new EventEmitter<string>();
@Output() nameClick = new EventEmitter<ChatLogMessage>();
innerWidth = 0;
// TODO: move to game ?
local: ChatLogMessage[] = [];
party: ChatLogMessage[] = [];
whisper: ChatLogMessage[] = [];
unread = 0;
private subscriptions: Subscription[] = [];
private startX = 0;
private startY = 0;
private shouldScrollToEnd = false;
private scrolledToEnd = true;
private scrollingToEnd = false;
private scrollToEndAtFrame = false;
private indexes = new Map<string, IndexEntry>();
private messageCounter = 0;
private lastOpacity = 0;
constructor(
private game: PonyTownGame,
private settingsService: SettingsService,
private element: ElementRef,
private zone: NgZone,
) {
// TODO: just put reference to chatlog on game ???
this.subscriptions.push(
game.onFrame.subscribe(() => {
if (this.scrollToEndAtFrame) {
this.scrollToEndAtFrame = false;
this.scrollHandler();
}
}),
game.onMessage.subscribe(message => {
this.addMessage(message);
}),
// TODO: move to game ?
game.onPonyAddOrUpdate.subscribe(pony => {
addOrUpdatePony(pony, this.local);
addOrUpdatePony(pony, this.party);
addOrUpdatePony(pony, this.whisper);
}),
game.onJoined.subscribe(() => {
if (!DEVELOPMENT) {
// TODO: move to game ?
this.local = [];
this.party = [];
this.whisper = [];
this.messageCounter = 0;
this.indexes = new Map();
this.clearList();
}
}),
game.onEntityIdUpdate.subscribe(update => {
updateEntityId(this.local, update.old, update.new);
updateEntityId(this.party, update.old, update.new);
updateEntityId(this.whisper, update.old, update.new);
}),
);
}
get linesElement() {
return this.lines.nativeElement as HTMLElement;
}
private updateOpen() {
this.updateChatlog();
if (this.open) {
this.setUnread(0);
this.regenerateList();
this.scrollToEnd();
} else {
this.clearList();
}
this.updateInnerWidth();
}
private updateChatlog() {
const element = this.chatLog.nativeElement as HTMLElement;
element.style.display = this.open ? 'flex' : 'none';
if (this.open) {
element.style.width = `${this.width}px`;
element.style.height = `${this.height}px`;
}
}
ngAfterViewInit() {
this.game.findEntityFromChatLog = this.findEntityFromMessages;
this.game.findEntityFromChatLogByName = this.findEntityFromMessagesByName;
this.updateTabs();
this.updateOpen();
this.zone.runOutsideAngular(() => {
const scroll = this.scroll.nativeElement as HTMLElement;
scroll.addEventListener('scroll', () => {
if (this.scrollingToEnd) {
this.scrolledToEnd = true;
this.scrollingToEnd = false;
} else {
const clientHeight = scroll.getBoundingClientRect().height;
this.scrolledToEnd = scroll.scrollTop >= (scroll.scrollHeight - clientHeight - SCROLL_END_THRESHOLD);
}
});
});
setTimeout(() => {
this.scrollToEnd();
this.updateInnerWidth();
});
if (DEVELOPMENT) {
sampleMessages.forEach(({ name, id, message, type }) =>
this.addMessage({ id: id || 999999, crc: undefined, name, message, type: type || MessageType.Chat }));
}
}
ngOnDestroy() {
if (this.game.findEntityFromChatLog === this.findEntityFromMessages) {
this.game.findEntityFromChatLog = () => undefined;
}
if (this.game.findEntityFromChatLogByName === this.findEntityFromMessagesByName) {
this.game.findEntityFromChatLogByName = () => undefined;
}
this.subscriptions.forEach(s => s.unsubscribe());
this.subscriptions = [];
}
ngDoCheck() {
if (this.lastOpacity !== this.opacity) {
this.lastOpacity = this.opacity;
this.contentElement.nativeElement.style.backgroundColor = this.bg;
this.updateTabs();
}
}
@HostListener('window:resize')
updateInnerWidth() {
const maxWidth = (this.element.nativeElement as HTMLElement).getBoundingClientRect().width;
const innerWidth = Math.min(maxWidth || this.width, this.width) - 40;
if (this.innerWidth !== innerWidth) {
this.innerWidth = innerWidth;
this.linesElement.style.width = `${innerWidth}px`;
}
if (!maxWidth) {
setTimeout(() => this.updateInnerWidth(), 10);
}
}
get messages() {
return this[this.activeTab];
}
get settings() {
return this.settingsService.browser;
}
get settings2() {
return this.settingsService.account;
}
get activeTab(): Tab {
const tab = this.settings.chatlogTab;
return (tab === 'local' || tab === 'party' || tab === 'whisper') ? tab : 'local';
}
get open() {
return !this.settings.chatlogClosed;
}
get width() {
return this.settings.chatlogWidth || 500;
}
get height() {
return this.settings.chatlogHeight || 310;
}
get opacity() {
return this.settings2.chatlogOpacity === undefined ? DEFAULT_CHATLOG_OPACITY : this.settings2.chatlogOpacity;
}
get bg() {
return `rgba(0, 0, 0, ${this.opacity / 100})`;
}
get inactiveBg() {
return `rgba(0, 0, 0, ${(this.opacity / 200) * 0.5})`;
}
private createEntry({ id, crc, name, message, type }: ChatMessage): ChatLogMessage {
const system = type === MessageType.System;
const entry: ChatLogMessage = {
entityId: system ? 0 : id,
name: system ? '' : name,
index: 0,
crc,
message,
label: LABELS[type] || '',
prefix: PREFIXES[type] || '',
suffix: SUFFIXES[type] || '',
classes: CLASSES[type] || '',
};
if (!system) {
entry.index = this.findOrCreateIndex(name, id, crc);
}
return entry;
}
private findOrCreateIndex(name: string, id: number, crc: number | undefined) {
let found = this.indexes.get(name);
if (!found || (this.messageCounter - found.counter) > FORGET_INDEX_AFTER) {
found = {
users: [{ id, crc }],
counter: 0,
};
this.indexes.set(name, found);
}
found.counter = this.messageCounter;
let index = findUserIndex(found.users, id, crc);
if (index === -1) {
index = found.users.length;
found.users.push({ id, crc });
}
return index;
}
addMessage(message: ChatMessage) {
if (message.name && message.message) {
const entry = this.createEntry(message);
const party = isPartyMessage(message.type);
const whisper = isWhisper(message.type) || isWhisperTo(message.type);
const open = this.open;
const scrolledToEnd = open ? this.scrolledToEnd : false;
const tab = this.activeTab;
this.addEntryToList(this.local, GENERAL_CHAT_LIMIT, open && tab === 'local', entry);
if (party || whisper) {
const partyEntry = { ...entry };
partyEntry.dom = undefined;
partyEntry.label = whisper ? partyEntry.label : undefined;
this.addEntryToList(this.party, PARTY_CHAT_LIMIT, open && tab === 'party', partyEntry);
}
if (whisper) {
const whisperEntry = { ...entry };
whisperEntry.dom = undefined;
whisperEntry.label = undefined;
this.addEntryToList(this.whisper, WHISPER_CHAT_LIMIT, open && tab === 'whisper', whisperEntry);
}
if (message.type === MessageType.Whisper && !this.open) {
this.setUnread(this.unread + 1);
}
if (scrolledToEnd) {
this.scrollToEnd();
}
this.messageCounter++;
}
}
private addEntryToList(list: ChatLogMessage[], limit: number, isOpen: boolean, entry: ChatLogMessage) {
let removedDom: ChatLogLineDOM | undefined;
while (list.length >= limit) {
const removed = list.shift();
if (isOpen && removed && removed.dom) {
if (removed.dom.root.parentElement) {
removed.dom.root.parentElement.removeChild(removed.dom.root);
}
removedDom = removed.dom;
removed.dom = undefined;
}
}
list.push(entry);
if (isOpen) {
entry.dom = removedDom || createChatLogLineDOM(this.clickLabel, this.clickNameHandler);
updateChatLogLine(entry.dom, entry);
this.linesElement.appendChild(entry.dom.root);
}
}
toggle() {
this.settings.chatlogClosed = !this.settings.chatlogClosed;
this.settingsService.saveBrowserSettings();
this.updateOpen();
}
switchTab(tab: Tab) {
if (this.activeTab !== tab) {
this.settings.chatlogTab = tab;
this.settingsService.saveBrowserSettings();
this.regenerateList();
this.scrollToEnd();
this.updateTabs();
}
}
private updateTabs() {
this.setActiveTab(this.localTab.nativeElement, this.activeTab === 'local');
this.setActiveTab(this.partyTab.nativeElement, this.activeTab === 'party');
this.setActiveTab(this.whisperTab.nativeElement, this.activeTab === 'whisper');
}
private setActiveTab(tab: HTMLElement, active: boolean) {
if (active) {
tab.classList.add('active');
tab.style.backgroundColor = this.bg;
} else {
tab.classList.remove('active');
tab.style.backgroundColor = this.inactiveBg;
}
}
scrollToEnd() {
// requestAnimationFrame(this.scrollHandler);
this.scrollToEndAtFrame = true;
}
scrollHandler = () => {
this.scrollingToEnd = true;
this.scroll.nativeElement.scrollTop = 99999;
}
clickNameHandler = (message: ChatLogMessage) => {
this.zone.run(() => this.nameClick.emit(message));
}
clickLabel = (message: ChatLogMessage) => {
this.zone.run(() => {
if (message.label) {
this.toggleType.emit(message.label);
}
});
}
private clearList() {
removeAllNodes(this.linesElement);
}
private regenerateList() {
this.clearList();
const lines = this.linesElement;
this.messages.forEach(entry => {
if (!entry.dom) {
entry.dom = createChatLogLineDOM(this.clickLabel, this.clickNameHandler);
updateChatLogLine(entry.dom, entry);
}
lines.appendChild(entry.dom.root);
});
}
drag({ x, y, type, event }: AgDragEvent, resizeY: boolean, resizeX: boolean) {
event.preventDefault();
if (type === 'start') {
const { left, top } = (this.element.nativeElement as HTMLElement).getBoundingClientRect();
this.startX = left;
this.startY = top;
this.shouldScrollToEnd = this.scrolledToEnd;
}
if (resizeX) {
this.settings.chatlogWidth = clamp(x - this.startX, 200, 2000);
}
if (resizeY) {
this.settings.chatlogHeight = clamp(this.startY - y, 120, 2000);
}
this.updateChatlog();
this.updateInnerWidth();
if (type === 'end') {
this.settingsService.saveBrowserSettings();
}
if (this.shouldScrollToEnd) {
this.scrollToEnd();
}
}
private setUnread(value: number) {
if (this.unread !== value) {
this.unread = value;
const count = this.countElement.nativeElement as HTMLElement;
const toggle = this.toggleButton.nativeElement as HTMLElement;
if (value) {
count.textContent = value > 99 ? '99+' : `${value}`;
toggle.classList.add('has-unread');
} else {
count.textContent = '';
toggle.classList.remove('has-unread');
}
}
}
private findEntityFromMessages = (id: number): FakeEntity | undefined => {
return findEntityFromMessages(id, this.whisper) ||
findEntityFromMessages(id, this.party) ||
findEntityFromMessages(id, this.local);
}
private findEntityFromMessagesByName = (name: string): FakeEntity | undefined => {
return findEntityFromMessagesByName(name, this.game.playerId, this.whisper) ||
findEntityFromMessagesByName(name, this.game.playerId, this.party) ||
findEntityFromMessagesByName(name, this.game.playerId, this.local);
}
}
function findEntityFromMessages(id: number, messages: ChatLogMessage[]): FakeEntity | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].entityId === id) {
return { fake: true, id, type: PONY_TYPE, name: messages[i].name, crc: messages[i].crc };
}
}
return undefined;
}
function findEntityFromMessagesByName(
name: string, playerId: number | undefined, messages: ChatLogMessage[]
): FakeEntity | undefined {
const regex = new RegExp(`^${escapeRegExp(name)}$`, 'i');
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.name && message.entityId && message.entityId !== playerId && regex.test(message.name)) {
return { fake: true, id: message.entityId, type: PONY_TYPE, name: message.name, crc: message.crc };
}
}
return undefined;
}
@@ -0,0 +1,8 @@
button.check-box(
role="checkbox"
(click)="toggle()"
[class.disabled]="disabled"
[attr.aria-disabled]="disabled"
[attr.aria-label]="label || ''"
[attr.aria-checked]="checked")
fa-icon(*ngIf="checked" [icon]="icon" [fixedWidth]="true")
@@ -0,0 +1,30 @@
@import '../../../../styles/partials/variables';
.check-box {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
height: $input-height;
width: $input-height;
margin: 0;
color: $input-color;
background-color: $input-bg;
border: 1px solid $border-color;
border-radius: $input-border-radius;
appearance: none;
&:focus {
outline: none;
box-shadow: 0 0 0 $btn-focus-width rgba($focus-color, .5);
}
&.disabled {
background: $input-disabled-bg;
color: $input-disabled-color;
}
> fa-icon {
font-size: 17px;
}
}
@@ -0,0 +1,22 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { faCheck } from '../../../client/icons';
@Component({
selector: 'check-box',
templateUrl: 'check-box.pug',
styleUrls: ['check-box.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckBox {
@Input() icon = faCheck;
@Input() label?: string;
@Input() disabled = false;
@Input() checked = false;
@Output() checkedChange = new EventEmitter<boolean>();
toggle() {
if (!this.disabled) {
this.checked = !this.checked;
this.checkedChange.emit(this.checked);
}
}
}
@@ -0,0 +1,26 @@
.color-picker.dropdown(
[class.disabled]="isDisabled" [class.open]="isOpen" [class.show]="isOpen")
//-[class.indicator]="indicatorColor"
.color-picker-box([style.background-color]="bg")
input.form-control.color-picker-input(
#input
type="text"
spellcheck="false"
(focus)="focus($event)"
(blur)="close()"
(input)="inputChanged(input.value)"
[value]="inputColor"
[disabled]="isDisabled"
[attr.aria-labelledby]="labelledBy"
[attr.aria-label]="label")
//-[style.border-left-color]="indicatorColor"
a.color-picker-chevron((mousedown)="toggleOpen()" [hidden]="isDisabled" aria-label="Pick color")
fa-icon([icon]="chevronIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right.color-picker-menu.show(
(mousedown)="stopEvent($event)" *ngIf="isOpen" aria-hidden="true")
.color-picker-sv(
[style.background-color]="hue" (agDrag)="dragSV($event)" agDragRelative="self" [agDragPrevent]="true")
.color-picker-sv-overlay
.color-wheel-circle-sv([style.left.%]="svLeft" [style.top.%]="svTop")
.color-picker-hue((agDrag)="dragHue($event)" agDragRelative="self" [agDragPrevent]="true")
.color-wheel-circle-hue([style.top.%]="hueTop")
@@ -0,0 +1,123 @@
@import '../../../../styles/partials/variables';
@import '../../../../styles/partials/mixins';
$color-picker-height: 175px;
$color-picker-hue-width: 28px;
$color-picker-sv-width: $color-picker-height;
$color-picker-indicator-size: 5px;
$color-picker-box-offset: 5px;
.color-picker {
position: relative;
display: inline-block;
width: 100%;
font-size: 1rem;
}
.color-picker-input {
padding-left: 35px;
padding-right: 40px;
width: 100% !important;
font-family: $font-family-monospace;
.indicator > & {
border-left-width: $color-picker-indicator-size;
}
}
.color-picker-box {
background: black;
position: absolute;
left: $color-picker-box-offset;
top: 5px;
bottom: 5px;
width: 25px;
border: solid 1px $border-color;
border-radius: 2px;
pointer-events: none;
z-index: 10;
.disabled > & {
opacity: 0.8;
}
.indicator > & {
left: $color-picker-box-offset + $color-picker-indicator-size;
}
}
.color-picker-chevron {
position: absolute;
top: 0;
right: 0;
color: $text-muted !important;
width: 40px;
height: 100%;
padding: 7px 0;
text-align: center;
}
.color-picker-menu {
@include nipple(10px);
padding: 5px;
display: flex;
}
.color-picker-sv {
position: relative;
width: $color-picker-sv-width;
height: $color-picker-height;
margin-right: 5px;
}
.color-picker-hue {
background: linear-gradient(to bottom, red, yellow, lime, cyan, blue, magenta, red);
position: relative;
width: $color-picker-hue-width;
height: $color-picker-height;
}
.color-picker-sv-overlay {
position: absolute;
left: 0;
top: 0;
width: $color-picker-sv-width;
height: $color-picker-height;
background:
linear-gradient(to bottom, rgba(0, 0, 0, 0), black),
linear-gradient(to right, white, rgba(255, 255, 255, 0));
}
.color-wheel-circle-sv, .color-wheel-circle-hue {
position: absolute;
left: 0;
top: 0;
&::after {
content: '';
position: absolute;
border: solid 1px black;
box-shadow: 0 0 0 2px white;
}
}
.color-wheel-circle-sv{
&::after {
left: -4px;
top: -4px;
border-radius: 4px;
width: 9px;
height: 9px;
}
}
.color-wheel-circle-hue {
width: 100%;
&::after {
left: 0;
top: -2px;
right: 0;
height: 5px;
}
}
@@ -0,0 +1,125 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { clamp } from '../../../common/utils';
import { parseColorFast, colorToCSS, colorFromHSVA, colorToHSVA, colorToHexRGB } from '../../../common/color';
import { AgDragEvent } from '../directives/agDrag';
import { faChevronDown } from '../../../client/icons';
const SIZE = 175;
@Component({
selector: 'color-picker',
templateUrl: 'color-picker.pug',
styleUrls: ['color-picker.scss'],
})
export class ColorPicker {
readonly chevronIcon = faChevronDown;
@Input() isOpen = false;
@Input() isDisabled = false;
@Input() disabledColor = '';
@Input() color = '';
@Input() indicatorColor = '';
@Input() label?: string = undefined;
@Input() labelledBy?: string = undefined;
@Output() colorChange = new EventEmitter<string>();
s = 0;
v = 0;
h = 0;
private lastColor = '';
private closeHandler = () => this.close();
get inputColor() {
return this.isDisabled && this.disabledColor ? this.disabledColor : this.color;
}
set inputColor(value) {
if (!this.isDisabled) {
this.color = value;
}
}
get bg() {
return colorToCSS(parseColorFast(this.inputColor));
}
get svLeft() {
this.updateHsv();
return this.s * 100;
}
get svTop() {
this.updateHsv();
return (1 - this.v) * 100;
}
get hueTop() {
this.updateHsv();
return this.h * 100 / 360;
}
get hue() {
this.updateHsv();
return colorToCSS(colorFromHSVA(this.h, 1, 1, 1));
}
focus(e: Event) {
this.isOpen = true;
(e.target as HTMLInputElement).select();
}
dragSV({ event, x, y }: AgDragEvent) {
event.preventDefault();
this.updateHsv();
this.s = clamp(x / SIZE, 0, 1);
this.v = 1 - clamp(y / SIZE, 0, 1);
this.updateColor();
}
dragHue({ event, y }: AgDragEvent) {
event.preventDefault();
this.updateHsv();
this.h = clamp(360 * y / SIZE, 0, 360);
this.updateColor();
}
updateHsv() {
if (this.lastColor !== this.color) {
const { h, s, v } = colorToHSVA(parseColorFast(this.color), this.h);
this.h = h;
this.s = s;
this.v = v;
this.lastColor = this.color;
}
}
updateColor() {
const color = colorToHexRGB(colorFromHSVA(this.h, this.s, this.v, 1));
const changed = this.color !== color;
this.lastColor = this.color = color;
if (changed) {
this.colorChange.emit(color);
}
}
inputChanged(value: string) {
this.color = value;
this.colorChange.emit(this.color);
}
stopEvent(e: Event) {
e.stopPropagation();
e.preventDefault();
}
open() {
if (!this.isOpen) {
this.isOpen = true;
setTimeout(() => {
document.addEventListener('mousedown', this.closeHandler);
document.addEventListener('touchstart', this.closeHandler);
});
}
}
close() {
this.isOpen = false;
document.removeEventListener('mousedown', this.closeHandler);
document.removeEventListener('touchstart', this.closeHandler);
}
toggleOpen() {
if (!this.isDisabled) {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
}
}
@@ -0,0 +1,11 @@
.custom-control.custom-checkbox.mb-2([class.disabled]="disabled")
label
input.custom-control-input(
type="checkbox"
[disabled]="disabled"
[(ngModel)]="checked"
(ngModelChange)="checkedChange.emit(checked)"
[attr.aria-describedby]="help ? helpId : undefined")
.custom-control-label
ng-content
small.form-text.text-muted.mt-0(*ngIf="help" [id]="helpId") {{help}}
@@ -0,0 +1,8 @@
.disabled {
opacity: 0.6;
}
label {
line-height: 1.4rem;
margin-bottom: 0;
}
@@ -0,0 +1,16 @@
import { Component, ChangeDetectionStrategy, Output, Input, EventEmitter } from '@angular/core';
import { uniqueId } from 'lodash';
@Component({
selector: 'custom-checkbox',
templateUrl: 'custom-checkbox.pug',
styleUrls: ['custom-checkbox.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomCheckbox {
@Input() disabled = false;
@Input() help = '';
@Input() checked = false;
@Output() checkedChange = new EventEmitter<boolean>();
helpId = uniqueId('custom-checkbox-help-');
}
@@ -0,0 +1,10 @@
.d-flex
select.form-control.w-50(placeholder="Day" [(ngModel)]="day" (ngModelChange)="change()")
option(disabled [ngValue]="0") Day
option(*ngFor="let d of days" [ngValue]="d") {{d}}
select.form-control.ml-2(placeholder="Month" [(ngModel)]="month" (ngModelChange)="change()")
option(disabled [ngValue]="0") Month
option(*ngFor="let m of months; let i = index" [ngValue]="i + 1") {{m}}
select.form-control.ml-2.w-50(placeholder="Year" [(ngModel)]="year" (ngModelChange)="change()")
option(disabled [ngValue]="0") Year
option(*ngFor="let y of years" [ngValue]="y") {{y}}
@@ -0,0 +1,55 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { times, formatISODate, parseISODate, createValidBirthDate } from '../../../common/utils';
import { MONTH_NAMES_EN } from '../../../common/constants';
import { getLocale } from '../../../client/clientUtils';
@Component({
selector: 'date-picker',
templateUrl: 'date-picker.pug',
})
export class DatePicker {
readonly days = times(31, i => i + 1);
readonly years: number[] = [];
readonly months = getMonthNames();
day = 0;
month = 0;
year = 0;
@Output() dateChange = new EventEmitter<string | undefined>();
constructor() {
const minYear = 1914;
const maxYear = (new Date()).getFullYear() - 6;
for (let year = maxYear; year >= minYear; year--) {
this.years.push(year);
}
}
@Input() get date() {
const date = createValidBirthDate(this.day, this.month, this.year);
return date && formatISODate(date);
}
set date(value) {
if (value) {
const { day, month, year } = parseISODate(value);
this.day = day;
this.month = month;
this.year = year;
}
}
change() {
this.dateChange.emit(this.date);
}
}
function getMonthNames() {
try {
const format = new Intl.DateTimeFormat(getLocale(), { month: 'long' });
return times(12, i => {
const date = new Date(523456789);
date.setMonth(i);
return format.format(date);
});
} catch {
return MONTH_NAMES_EN;
}
}
@@ -0,0 +1,12 @@
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
@Directive({
selector: '[agAutoFocus]'
})
export class AgAutoFocus implements AfterViewInit {
constructor(private element: ElementRef) {
}
ngAfterViewInit() {
setTimeout(() => this.element.nativeElement.focus(), 100);
}
}
@@ -0,0 +1,140 @@
import { Directive, OnInit, Input, Output, EventEmitter, ElementRef, OnDestroy } from '@angular/core';
import { noop } from 'lodash';
import { getButton, getX, getY, AnyEvent } from '../../../common/utils';
export interface AgDragEvent {
event: AnyEvent;
type: 'start' | 'drag' | 'end';
x: number;
y: number;
dx: number;
dy: number;
}
export interface AgDragOptions {
relative?: 'self' | 'parent';
prevent?: boolean;
}
export function handleDrag(element: HTMLElement, emit: (event: AgDragEvent) => void, options: AgDragOptions = {}) {
// typeof PointerEvent !== 'undefined'
const eventSets = window.navigator.pointerEnabled ? [
{ down: 'pointerdown', move: 'pointermove', up: 'pointerup' }, // , up2: 'pointercancel' },
] : [
{ down: 'mousedown', move: 'mousemove', up: 'mouseup' },
{ down: 'touchstart', move: 'touchmove', up: 'touchend', up2: 'touchcancel' },
];
const emptyRect = { left: 0, top: 0 };
let rect = emptyRect;
let scrollLeft = 0;
let scrollTop = 0;
let startX = 0;
let startY = 0;
let button = 0;
let dragging = false;
let lastEvent: any;
function setupScrollAndRect() {
// TODO: fix issue with scroll
switch (options.relative) {
case 'self':
rect = element.getBoundingClientRect();
scrollLeft = -(window.scrollX || window.pageXOffset || 0);
scrollTop = -(window.scrollY || window.pageYOffset || 0);
break;
case 'parent':
rect = element.parentElement!.getBoundingClientRect();
scrollLeft = element.parentElement!.scrollLeft;
scrollTop = element.parentElement!.scrollTop;
break;
default:
rect = emptyRect;
scrollLeft = 0;
scrollTop = 0;
}
}
function send(event: AnyEvent, type: 'start' | 'drag' | 'end') {
const x = getX(event);
const y = getY(event);
emit({
event,
type,
x: x - rect.left + scrollLeft,
y: y - rect.top + scrollTop,
dx: x - startX,
dy: y - startY,
});
}
const handlers = eventSets.map(events => {
function move(e: any) {
lastEvent = e;
e.preventDefault();
send(e, 'drag');
}
function up(e: any) {
if (getButton(e) === button) {
// touchend event does not have x, y coordinates, use last touchmove event instead
if (e.type !== 'touchend' && e.type !== 'touchcancel') {
lastEvent = e;
}
end();
}
}
function end() {
send(lastEvent, 'end');
window.removeEventListener(events.move, move);
window.removeEventListener(events.up, up);
events.up2 && window.removeEventListener(events.up2, up);
window.removeEventListener('blur', end);
dragging = false;
}
function handler(e: any) {
if (!dragging) {
setupScrollAndRect();
dragging = true;
button = getButton(e);
startX = getX(e);
startY = getY(e);
send(e, 'start');
lastEvent = e;
window.addEventListener(events.move, move);
window.addEventListener(events.up, up);
events.up2 && window.addEventListener(events.up2, up);
window.addEventListener('blur', end);
e.stopPropagation();
if (options.prevent) {
e.preventDefault();
}
}
}
element.addEventListener(events.down, handler);
return () => element.removeEventListener(events.down, handler);
});
return () => handlers.forEach(f => f());
}
@Directive({ selector: '[agDrag]' })
export class AgDrag implements OnInit, OnDestroy {
@Input('agDragRelative') relative: 'self' | 'parent' | undefined = undefined;
@Input('agDragPrevent') prevent = false;
@Output('agDrag') drag = new EventEmitter<AgDragEvent>();
private unsubscribe = noop;
constructor(private element: ElementRef) {
}
ngOnInit() {
this.unsubscribe = handleDrag(this.element.nativeElement, e => this.drag.emit(e), this);
}
ngOnDestroy() {
this.unsubscribe();
}
}
@@ -0,0 +1,17 @@
import { Directive, OnInit, ElementRef } from '@angular/core';
@Directive({
selector: 'a[href]'
})
export class Anchor implements OnInit {
constructor(private element: ElementRef) {
}
ngOnInit() {
const a = this.element.nativeElement as HTMLAnchorElement;
if (/^(https?|mailto):/.test(a.href) && !a.target) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
}
}
}
@@ -0,0 +1,30 @@
import { Directive, Input, Optional } from '@angular/core';
import { NgModel } from '@angular/forms';
@Directive({
selector: '[btnHighlight]',
host: {
'[class.btn-default]': '!on',
'[class.btn-primary]': 'on',
},
})
export class BtnHighlight {
@Input() btnHighlight?: boolean = undefined;
constructor(@Optional() private model?: NgModel) {
}
get on() {
const value = this.btnHighlight;
return (value === true || value === false || !this.model) ? value : !!this.model.value;
}
}
@Directive({
selector: '[btnHighlightDanger]',
host: {
'[class.btn-default]': '!btnHighlightDanger',
'[class.btn-danger]': 'btnHighlightDanger',
},
})
export class BtnHighlightDanger {
@Input() btnHighlightDanger = false;
}
@@ -0,0 +1,207 @@
import { Component, Directive, Input, Output, EventEmitter, ElementRef, OnInit, Injectable, OnDestroy } from '@angular/core';
import { noop } from 'lodash';
import { AgDragEvent, handleDrag } from './agDrag';
import { clamp, setTransform, removeItem, pointInRect } from '../../../common/utils';
import { rect } from '../../../common/rect';
@Injectable({ providedIn: 'root' })
export class DraggableService {
root?: ElementRef;
draggedItem?: any;
activeDropZone?: DraggableDrop<any>;
dropZones: DraggableDrop<any>[] = [];
get rootElement(): HTMLElement {
return this.root ? this.root.nativeElement : document.body;
}
setActiveDropZone(dropZone: DraggableDrop<any> | undefined) {
if (this.activeDropZone !== dropZone) {
if (this.activeDropZone) {
this.activeDropZone.setActive(false);
}
this.activeDropZone = dropZone;
if (this.activeDropZone) {
this.activeDropZone.setActive(true);
}
}
}
startMove(element: HTMLElement, item: any) {
this.setActiveDropZone(undefined);
this.rootElement.appendChild(element);
this.draggedItem = item;
this.initRects();
}
endMove() {
if (this.activeDropZone) {
this.activeDropZone.drop.emit(this.draggedItem);
this.setActiveDropZone(undefined);
}
this.draggedItem = undefined;
}
addDropZone(dropZone: DraggableDrop<any>) {
this.dropZones.push(dropZone);
if (this.draggedItem) {
this.initRects();
}
}
removeDropZone(dropZone: DraggableDrop<any>) {
removeItem(this.dropZones, dropZone);
if (this.draggedItem) {
this.initRects();
}
if (this.activeDropZone === dropZone) {
this.setActiveDropZone(undefined);
}
}
updateHover(x: number, y: number) {
if (this.draggedItem) {
for (const zone of this.dropZones) {
if (pointInRect(x, y, zone.rect)) {
this.setActiveDropZone(zone);
return;
}
}
this.setActiveDropZone(undefined);
}
}
private initRects() {
this.dropZones.forEach(i => i.initRect());
}
}
@Component({
selector: 'draggable-outlet',
template: `<div></div>`,
styles: [`:host { position: fixed; top: 0; left: 0; z-index: 10000; }`],
})
export class DraggableOutlet {
constructor(element: ElementRef, service: DraggableService) {
service.root = element;
}
}
@Directive({ selector: '[draggableDrop]' })
export class DraggableDrop<T> implements OnInit, OnDestroy {
@Input('draggablePad') pad = 0;
@Output('draggableDrop') drop = new EventEmitter<T>();
rect = rect(0, 0, 0, 0);
constructor(private element: ElementRef, private service: DraggableService) {
}
ngOnInit() {
this.service.addDropZone(this);
}
ngOnDestroy() {
this.service.removeDropZone(this);
}
setActive(active: boolean) {
const element = this.element.nativeElement as HTMLElement;
if (active) {
element.classList.add('draggable-hover');
} else {
element.classList.remove('draggable-hover');
}
}
initRect() {
const element = this.element.nativeElement as HTMLElement;
const clientBounds = element.getBoundingClientRect();
this.rect.x = clientBounds.left - this.pad;
this.rect.y = clientBounds.top - this.pad;
this.rect.w = clientBounds.width + 2 * this.pad;
this.rect.h = clientBounds.height + 2 * this.pad;
}
}
@Directive({
selector: '[draggableItem]',
host: {
'[style.touch-action]': `touchAction`,
}
})
export class DraggableItem<T> implements OnInit, OnDestroy {
@Input('draggableItem') item: T | undefined;
@Output('draggableDrag') dragStarted = new EventEmitter<void>();
private startX = 0;
private startY = 0;
private draggable?: HTMLElement;
private width = 0;
private height = 0;
private unsubscribeDrag = noop;
private _disabled = false;
constructor(private element: ElementRef, private service: DraggableService) {
}
ngOnInit() {
this.setupDragEvents();
}
ngOnDestroy() {
this.unsubscribeDrag();
}
get touchAction() {
return this.disabled ? 'inherit' : 'none';
}
@Input('draggableDisabled') get disabled() {
return this._disabled;
}
set disabled(value) {
if (this._disabled !== value) {
this._disabled = value;
this.setupDragEvents();
}
}
private setupDragEvents() {
this.unsubscribeDrag();
this.unsubscribeDrag = noop;
if (!this.disabled) {
this.unsubscribeDrag = handleDrag(this.element.nativeElement, e => this.drag(e), { prevent: true });
}
}
drag(e: AgDragEvent) {
if (this.item && !this.disabled && !this.draggable && (Math.abs(e.dx) > 5 || Math.abs(e.dy) > 5)) {
const element = this.element.nativeElement as HTMLElement;
const rect = element.getBoundingClientRect();
this.startX = rect.left;
this.startY = rect.top;
this.draggable = element.cloneNode(true) as HTMLElement;
this.draggable.style.position = 'absolute';
this.draggable.style.width = `${rect.width}px`;
this.draggable.style.height = `${rect.height}px`;
this.draggable.style.margin = '0';
this.draggable.classList.add('draggable-dragging');
this.width = rect.width;
this.height = rect.height;
const src = element.querySelectorAll('canvas') as NodeListOf<HTMLCanvasElement>;
const dst = this.draggable.querySelectorAll('canvas') as NodeListOf<HTMLCanvasElement>;
for (let i = 0; i < src.length; i++) {
const context = dst.item(i).getContext('2d');
context && context.drawImage(src.item(i), 0, 0);
}
this.service.startMove(this.draggable, this.item!);
this.dragStarted.emit();
}
if (this.draggable) {
if (e.type === 'end') {
this.draggable!.parentNode!.removeChild(this.draggable!);
this.draggable = undefined;
this.service.endMove();
} else {
const x = clamp(this.startX + e.dx, 0, window.innerWidth - this.width);
const y = clamp(this.startY + e.dy, 0, window.innerHeight - this.height);
setTransform(this.draggable, `translate3d(${x}px, ${y}px, 0px)`);
this.service.updateHover(e.x, e.y);
}
}
}
}
export const draggableComponents = [DraggableOutlet, DraggableItem, DraggableDrop];
@@ -0,0 +1,227 @@
import {
Directive, HostListener, Input, Output, EventEmitter, TemplateRef, ViewContainerRef, ContentChild,
Renderer2, ElementRef, EmbeddedViewRef, Component, Injectable
} from '@angular/core';
import { uniqueId } from 'lodash';
import { focusFirstElement } from '../../../client/htmlUtils';
@Injectable({ providedIn: 'root' })
export class DropdownOutletService {
viewContainer?: ViewContainerRef;
rootElement?: HTMLElement;
}
@Component({
selector: 'dropdown-outlet',
template: `<ng-template></ng-template>`,
})
export class DropdownOutlet {
constructor(service: DropdownOutletService, viewContainer: ViewContainerRef, element: ElementRef) {
service.viewContainer = viewContainer;
service.rootElement = element.nativeElement.parentElement;
}
}
@Directive({
selector: '[dropdownMenu]',
})
export class DropdownMenu {
ref?: EmbeddedViewRef<any>;
id = uniqueId('dropdown-menu-');
private onClose?: () => void;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private renderer: Renderer2,
private service: DropdownOutletService,
) {
}
private get root(): HTMLElement {
return this.ref && this.ref.rootNodes[0];
}
open(useOutlet: boolean, rootElement: HTMLElement) {
if (!this.ref) {
if (useOutlet) {
this.ref = this.service.viewContainer!.createEmbeddedView(this.templateRef);
} else {
this.ref = this.viewContainer.createEmbeddedView(this.templateRef);
}
const { renderer, root } = this;
renderer.addClass(root, 'show');
renderer.setAttribute(root, 'id', this.id);
if (useOutlet) {
const positionMenu = () => {
const rect = rootElement.getBoundingClientRect();
const menuRect = root.getBoundingClientRect();
let transform: string;
if ((rect.bottom + menuRect.height) > window.innerHeight) {
transform = `translate3d(${Math.round(rect.left)}px, ${Math.round(rect.top - menuRect.height)}px, 0)`;
renderer.addClass(root, 'dropdown-menu-up');
} else {
transform = `translate3d(${Math.round(rect.left)}px, ${Math.round(rect.bottom)}px, 0)`;
renderer.removeClass(root, 'dropdown-menu-up');
}
renderer.setStyle(root, 'transform', transform);
};
renderer.addClass(root, 'dropdown-in-outlet');
positionMenu();
const closeDropdown = () => {
this.close();
};
document.addEventListener('scroll', closeDropdown, true);
window.addEventListener('resize', closeDropdown, true);
this.onClose = () => {
document.removeEventListener('scroll', closeDropdown, true);
window.removeEventListener('resize', closeDropdown, true);
};
}
}
}
close() {
if (this.ref) {
this.ref.destroy();
this.ref = undefined;
}
if (this.onClose) {
this.onClose();
this.onClose = undefined;
}
}
checkTarget(e: Event) {
return this.root && this.root.contains(e.target as any);
}
focusFirstElement() {
if (this.root) {
focusFirstElement(this.root);
}
}
}
@Directive({
selector: '[dropdown]',
exportAs: 'ag-dropdown',
host: {
'[class.show]': 'isOpen',
},
})
export class Dropdown {
dropdownToggle?: DropdownToggle;
@ContentChild(DropdownMenu, { static: false }) menu!: DropdownMenu;
@Input() autoClose: boolean | 'outsideClick' = true;
@Input() preventAutoCloseOnOutlet = false;
@Input() hookToCanvas = false;
@Input() focusOnOpen = true;
@Input() focusOnClose = true;
@Input() useOutlet = false;
@Input() isOpen = false;
@Output() isOpenChange = new EventEmitter<boolean>();
get menuId() {
return this.isOpen ? this.menu.id : '';
}
constructor(private element: ElementRef, private service: DropdownOutletService) {
}
open() {
if (!this.isOpen) {
this.isOpen = true;
this.isOpenChange.emit(true);
this.menu.open(this.useOutlet, this.element.nativeElement);
setTimeout(() => {
document.addEventListener('click', this.closeHandler);
document.addEventListener('keydown', this.closeHandler);
if (this.focusOnOpen) {
this.menu.focusFirstElement();
}
if (this.hookToCanvas) {
const canvas = document.getElementById('canvas');
if (canvas) {
canvas.addEventListener('touchstart', this.canvasCloseHandler);
canvas.addEventListener('mousedown', this.canvasCloseHandler);
}
}
});
}
}
close() {
if (this.isOpen) {
this.isOpen = false;
this.isOpenChange.emit(false);
this.menu.close();
if (this.focusOnClose && this.dropdownToggle) {
this.dropdownToggle.focus();
}
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('keydown', this.closeHandler);
if (this.hookToCanvas) {
const canvas = document.getElementById('canvas');
if (canvas) {
canvas.removeEventListener('touchstart', this.canvasCloseHandler);
canvas.removeEventListener('mousedown', this.canvasCloseHandler);
}
}
}
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
private closeHandler: any = (e: KeyboardEvent) => {
if (
!e.keyCode
&& (this.autoClose || (this.dropdownToggle && this.dropdownToggle.checkTarget(e)))
&& !(this.preventAutoCloseOnOutlet && this.service.rootElement && this.service.rootElement.contains(e.target as any))
&& !(this.autoClose === 'outsideClick' && this.menu.checkTarget(e))
) {
this.close();
} else if (this.autoClose && e.keyCode === 27) { // esc
this.close();
}
}
private canvasCloseHandler: any = () => this.close();
}
@Directive({
selector: '[dropdownToggle]',
host: {
'aria-haspopup': 'true',
'[attr.aria-expanded]': 'dropdown.isOpen',
'[attr.aria-controls]': 'dropdown.isOpen ? dropdown.menuId : undefined',
},
})
export class DropdownToggle {
constructor(private element: ElementRef, public dropdown: Dropdown) {
dropdown.dropdownToggle = this;
}
@HostListener('click')
click() {
this.dropdown.toggle();
}
checkTarget(e: Event) {
return this.element.nativeElement.contains(e.target);
}
focus() {
this.element.nativeElement.focus();
}
}
export const dropdownDirectives = [Dropdown, DropdownToggle, DropdownMenu, DropdownOutlet];
@@ -0,0 +1,22 @@
import { Directive, ElementRef, Input, HostListener, HostBinding, Output, EventEmitter } from '@angular/core';
@Directive({
selector: '[fixToTop]',
})
export class FixToTop {
@Input() fixToTopOffset = 0;
@Output() fixToTop = new EventEmitter<boolean>();
@HostBinding('class.fixed-to-top') fixed = false;
constructor(private element: ElementRef) {
}
@HostListener('window:scroll')
scroll() {
const element = this.element.nativeElement as HTMLElement;
const { top } = element.getBoundingClientRect();
if (this.fixed !== top < this.fixToTopOffset) {
this.fixed = top < this.fixToTopOffset;
this.fixToTop.emit(this.fixed);
}
}
}
@@ -0,0 +1,15 @@
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
@Directive({
selector: '[focusTitle]',
host: {
'tabindex': '-1',
},
})
export class FocusTitle implements AfterViewInit {
constructor(private element: ElementRef) {
}
ngAfterViewInit() {
setTimeout(() => this.element.nativeElement.focus());
}
}
@@ -0,0 +1,57 @@
import { Directive, Input, OnDestroy, ElementRef, OnInit } from '@angular/core';
import { isParentOf, focusFirstElement, findFocusableElements } from '../../../client/htmlUtils';
import { isMobile } from '../../../client/data';
@Directive({
selector: '[focusTrap]',
})
export class FocusTrap implements OnInit, OnDestroy {
private on = true;
private lastActiveElement?: HTMLElement;
@Input() set focusTrap(value: boolean) {
if (this.on !== value) {
this.on = value;
this.update();
}
}
constructor(private element: ElementRef) {
}
ngOnInit() {
this.update();
}
ngOnDestroy() {
this.focusTrap = false;
}
private update() {
if (!isMobile) {
if (this.on) {
this.lastActiveElement = document.activeElement as HTMLElement;
document.addEventListener('focusin', this.focus);
if (!isParentOf(this.element.nativeElement, this.lastActiveElement)) {
setTimeout(() => this.lastActiveElement = focusFirstElement(this.element.nativeElement));
}
} else {
this.lastActiveElement = undefined;
document.removeEventListener('focusin', this.focus);
}
}
}
private focus = (e: Event) => {
if (isParentOf(this.element.nativeElement, e.target as any)) {
this.lastActiveElement = e.target as any;
} else {
const focusable = findFocusableElements(this.element.nativeElement);
if (focusable.length) {
if (this.lastActiveElement === focusable[0]) {
this.lastActiveElement = focusable[focusable.length - 1];
} else {
this.lastActiveElement = focusable[0];
}
this.lastActiveElement.focus();
}
}
}
}
@@ -0,0 +1,60 @@
import { Directive, Input, TemplateRef, ViewContainerRef, OnDestroy, EmbeddedViewRef, AfterViewInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { hasFeatureFlag, featureFlagsChanged } from '../../../client/clientUtils';
import { Model } from '../../services/model';
@Directive({
selector: '[hasFeature]',
})
export class HasFeature implements AfterViewInit, OnDestroy {
private subscriptions: Subscription[] = [];
private showing = false;
private _flag: string | undefined = undefined;
private _orMod = false;
private _alsoIf = true;
private ref?: EmbeddedViewRef<any>;
constructor(private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef, private model: Model) {
}
ngAfterViewInit() {
this.subscriptions.push(featureFlagsChanged.subscribe(() => this.update()));
this.subscriptions.push(this.model.accountChanged.subscribe(() => this.update()));
}
ngOnDestroy() {
this.subscriptions.forEach(s => s.unsubscribe());
}
@Input()
set hasFeature(value: string | undefined) {
if (this._flag !== value) {
this._flag = value;
this.update();
}
}
@Input()
set hasFeatureOrMod(value: boolean) {
if (this._orMod !== value) {
this._orMod = value;
this.update();
}
}
@Input()
set hasFeatureAlso(value: boolean) {
if (this._alsoIf !== value) {
this._alsoIf = value;
this.update();
}
}
private update() {
const show = this._alsoIf && (hasFeatureFlag(this._flag as any) || (this._orMod && this.model.isMod));
if (this.showing !== show) {
this.showing = show;
if (show) {
this.ref = this.ref || this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
this.ref = undefined;
}
}
}
}
@@ -0,0 +1,21 @@
import { Directive, Input, OnInit, ElementRef } from '@angular/core';
import { uniqueId } from 'lodash';
import { findParentElement } from '../../../client/htmlUtils';
@Directive({
selector: '[labelledBy]',
})
export class LabelledBy implements OnInit {
@Input('labelledBy') selector!: string;
constructor(private element: ElementRef) {
}
ngOnInit() {
const element = this.element.nativeElement as HTMLElement;
const target = findParentElement(element, this.selector);
const id = element.id = element.id || uniqueId('labelled-by-');
if (target) {
target.setAttribute('aria-labelledby', id);
}
}
}
@@ -0,0 +1,14 @@
import { Directive, HostBinding } from '@angular/core';
import { RouterLinkActive } from '@angular/router';
@Directive({
selector: '[linkCurrent]',
})
export class LinkCurrent {
constructor(private routerLinkActive: RouterLinkActive) {
}
@HostBinding('attr.aria-current')
get current() {
return this.routerLinkActive.isActive ? 'true' : undefined;
}
}
@@ -0,0 +1,12 @@
import { Directive, Input, HostBinding } from '@angular/core';
import { getUrl } from '../../../client/rev';
@Directive({
selector: '[revSrc]',
})
export class RevSrc {
@HostBinding() get src() {
return this.revSrc && getUrl(this.revSrc);
}
@Input() revSrc?: string;
}
@@ -0,0 +1,26 @@
import { Directive, Input, Host, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { Tabset } from '../tabset/tabset';
import { StorageService } from '../../services/storageService';
@Directive({
selector: '[saveActiveTab]',
})
export class SaveActiveTab implements OnInit, OnDestroy {
@Input('saveActiveTab') key!: string;
private subscription?: Subscription;
constructor(@Host() private tabset: Tabset, private storage: StorageService) {
}
ngOnInit() {
// this.tabset.activeIndex = parseInt(this.storage.getItem(this.key) || '0', 10);
this.tabset.select(parseInt(this.storage.getItem(this.key) || '0', 10));
this.subscription = this.tabset.activeIndexChange.subscribe((i: number) => {
this.storage.setItem(this.key, i.toString());
});
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}
@@ -0,0 +1,80 @@
import { Component, Input, AfterViewInit, ElementRef, ChangeDetectionStrategy, ViewChild, NgZone } from '@angular/core';
import { findEmoji, getEmojiImageAsync } from '../../../client/emoji';
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
import { font } from '../../../client/fonts';
import { getCharacterSprite } from '../../../graphics/spriteFont';
@Component({
selector: 'emote-box',
template: '<img #image class="emote-box pixelart" />',
styles: ['.emote-box { pointer-events: none; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmoteBox implements AfterViewInit {
@ViewChild('image', { static: true }) image!: ElementRef;
private emoteValue = '';
private scaleValue = 2;
private initialized = false;
constructor(private zone: NgZone) {
}
ngAfterViewInit() {
loadAndInitSpriteSheets()
.then(() => {
this.initialized = true;
this.zone.runOutsideAngular(() => this.redraw());
});
}
get emote() {
return this.emoteValue;
}
@Input()
set emote(value: string) {
if (this.emoteValue !== value) {
this.emoteValue = value;
this.zone.runOutsideAngular(() => this.redraw());
}
}
get scale() {
return this.scaleValue;
}
@Input()
set scale(value: number) {
if (this.scaleValue !== value) {
this.scaleValue = value;
this.zone.runOutsideAngular(() => this.redraw());
}
}
redraw() {
if (this.initialized) {
const emote = findEmoji(this.emote);
const sprite = font && emote && getCharacterSprite(emote.symbol, font);
const image = this.image.nativeElement as HTMLImageElement;
if (sprite) {
const width = sprite.w + sprite.ox;
const height = 10; // sprite.h + sprite.oy;
image.style.width = `${width * this.scale}px`;
image.style.height = `${height * this.scale}px`;
image.style.marginTop = `${-this.scale}px`;
image.style.display = 'inline-block';
image.style.visibility = 'hidden';
if (emote) {
image.setAttribute('aria-label', emote.names[0]);
}
getEmojiImageAsync(sprite, src => {
image.src = src;
image.alt = emote ? emote.symbol : '';
image.style.visibility = 'visible';
});
} else {
image.style.width = `0px`;
image.style.height = `0px`;
image.src = '';
image.alt = '';
}
}
}
}
@@ -0,0 +1,29 @@
.row.form-group(*ngIf="outlineHidden")
.col-4.col-lock-box
label.text-muted.color-label {{label}}
check-box.lock-box(
*ngIf="hasLock && !nonLockable" [(checked)]="locked" (checkedChange)="onLockedChange($event)"
[icon]="lockIcon" label="Automatic color")
.col-8
color-picker(
[(color)]="fill" (colorChange)="onFillChange($event)" [isDisabled]="locked" [label]="label || 'Color'"
[indicatorColor]="indicatorColor")
.row.form-group(*ngIf="!outlineHidden")
.col-4.col-sm-3.col-lock-box
label.text-muted.color-label {{label}}
check-box.lock-box(
*ngIf="hasLock && !nonLockable" [(checked)]="locked" (checkedChange)="onLockedChange($event)"
[icon]="lockIcon" label="Automatic color")
.col-8.col-sm-4
color-picker(
[(color)]="fill" (colorChange)="onFillChange($event)" [isDisabled]="locked" [label]="label + ' fill'"
[indicatorColor]="indicatorColor")
.col-4.col-sm-1.col-outline
check-box.lock-box(
[(checked)]="outlineLocked" (checkedChange)="onOutlineLockedChange($event)" [icon]="lockIcon"
label="Automatic outline")
.col-8.col-sm-4.col-outline
color-picker(
[(color)]="outline" (colorChange)="onOutlineChange($event)" [isDisabled]="outlineLocked"
[label]="label + ' outline'" [indicatorColor]="indicatorColor")
@@ -0,0 +1,18 @@
@import '../../../../styles/partials/variables';
@import '../../../../styles/partials/mixins';
.col-lock-box {
display: flex;
align-items: center;
justify-content: space-between;
> label {
margin: 0;
}
}
.col-outline {
@include media-breakpoint-down(xs) {
padding-top: 0.5rem;
}
}
@@ -0,0 +1,47 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { faLock } from '../../../client/icons';
@Component({
selector: 'fill-outline',
templateUrl: 'fill-outline.pug',
styleUrls: ['fill-outline.scss'],
})
export class FillOutline {
readonly lockIcon = faLock;
@Input() label = 'Color';
@Input() indicatorColor = '';
@Input() base?: string;
@Input() fill?: string;
@Output() fillChange = new EventEmitter<string>();
@Input() outline?: string;
@Output() outlineChange = new EventEmitter<string>();
@Input() locked?: boolean;
@Output() lockedChange = new EventEmitter<boolean>();
@Input() nonLockable = false;
@Input() outlineLocked = false;
@Output() outlineLockedChange = new EventEmitter<boolean>();
@Input() outlineHidden = false;
@Output() change = new EventEmitter<void>();
get hasLock() {
return this.locked !== undefined;
}
onChange() {
this.change.emit();
}
onFillChange(value: string) {
this.fillChange.emit(value);
this.onChange();
}
onOutlineChange(value: string) {
this.outlineChange.emit(value);
this.onChange();
}
onLockedChange(value: boolean) {
this.lockedChange.emit(value);
this.onChange();
}
onOutlineLockedChange(value: boolean) {
this.outlineLockedChange.emit(value);
this.onChange();
}
}
@@ -0,0 +1,55 @@
.friends-box.dropdown(
dropdown autoClose="outsideClick" (isOpenChange)="toggle()" [hookToCanvas]="true" [focusOnOpen]="false"
[preventAutoCloseOnOutlet]="true")
button.game-button.dropdown-toggle.no-arrow(dropdownToggle (click)="false" title="Friends")
fa-icon([icon]="friendsIcon" [fixedWidth]="true")
.friends-dropdown-menu.dropdown-menu.dropdown-menu-right(*dropdownMenu)
.friends-list-header.list-unstyled
.dropdown-header.d-flex
.flex-grow-1 Friends
.dropdown(dropdown)
button.dropdown-toggle.no-arrow(dropdownToggle)
span.text-success(*ngIf="!hidden") online
span.text-muted(*ngIf="hidden") offline
fa-icon.ml-2([icon]="cogIcon")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
button.dropdown-item((click)="setStatus('online')")
fa-icon.mr-2.text-success([icon]="statusIcon" size="xs" [fixedWidth]="true")
| Online
button.dropdown-item((click)="setStatus('invisible')")
fa-icon.mr-2.text-muted([icon]="statusIcon" size="xs" [fixedWidth]="true")
| Show as Offline
.friends-list(*ngIf="friends && friends.length; else noFriends")
//- [itemSize]="42")
.friends-item(
dropdown
*ngFor="let f of friends"
[ngClass]="f.online ? 'online' : 'offline'"
[useOutlet]="true"
[hookToCanvas]="true"
[focusOnOpen]="false"
[focusOnClose]="false")
button.d-flex(dropdownToggle)
portrait-box.mr-2([pony]="f.ponyInfo" size="small" [noBorder]="true")
.flex-grow-1.friends-item-details
.friends-item-name {{f.actualName}}&#160;
.friends-item-more
.text-success.float-right.ml-2(*ngIf="f.online") online
.text-muted.float-right.ml-2(*ngIf="!f.online") offline
.text-muted.friends-item-account {{f.accountName}}
.friends-item-delete.d-flex.px-2.py-1(*ngIf="removing === f")
button.btn.btn-sm.btn-outline-success.flex-grow-1((click)="cancelRemove(); $event.stopPropagation()")
| Cancel
button.btn.btn-sm.btn-outline-danger.flex-grow-1.ml-2((click)="confirmRemove(); $event.stopPropagation()")
| Confirm remove
//- span.badge.badge-none.friends-item-server PG
.options-dropdown-menu.dropdown-menu(*dropdownMenu)
button.dropdown-item((click)="sendMessageTo(f)" [disabled]="!f.online") Send whisper
button.dropdown-item((click)="inviteToParty(f)" [disabled]="!f.online") Invite to party
.dropdown-divider
button.dropdown-item((click)="remove(f)") Remove
ng-template(#noFriends)
.text-center.text-muted.p-2(*ngIf="friends")
| select a player and use #[fa-icon([icon]="userOptionsIcon")] menu to add them to your friends
.text-center.text-muted.p-2(*ngIf="!friends")
| Loading...
@@ -0,0 +1,107 @@
@import '../../../../styles/partials/variables';
@import '../../../../styles/partials/mixins';
.friends-box {
padding-bottom: 5px;
}
.friends-dropdown-menu {
@include nipple(8px);
min-width: 205px;
}
.options-dropdown-menu {
@include nippleLeft(11px);
min-width: 200px;
margin-left: 4px;
&.dropdown-menu-up {
@include nippleLeftFlip();
}
}
.friends-list-header {
margin-bottom: 0;
}
.friends-list {
width: 250px;
max-height: 500px;
overflow-y: auto;
margin-bottom: 0;
@media screen and (max-height: 600px) {
max-height: 70vh;
max-height: calc(100vh - 100px);
}
}
.friends-item {
height: 42px;
color: $dropdown-link-color;
padding: 0 8px;
cursor: pointer;
position: relative;
text-align: left;
> button {
width: 100%;
overflow: hidden;
text-align: left;
}
portrait-box {
margin-top: 5px;
}
&.offline {
> button {
opacity: 0.6;
}
}
&:hover {
background: $dropdown-link-hover-bg;
color: $dropdown-link-hover-color;
}
}
.friends-item-delete {
position: absolute;
background: white;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.friends-item-details {
overflow: hidden;
padding-bottom: 5px;
}
.friends-item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friends-item-more {
margin-bottom: -2px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friends-item-account {
overflow: hidden;
text-overflow: ellipsis;
}
.friends-item-server {
position: absolute;
left: 24px;
bottom: 0;
font-size: 8px;
}
@@ -0,0 +1,61 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { faCog, faUserFriends, faUserPlus, faUserCog, faCircle } from '../../../client/icons';
import { Model, Friend } from '../../services/model';
import { PonyTownGame } from '../../../client/game';
import { PlayerAction, Action } from '../../../common/interfaces';
import { removeItem } from '../../../common/utils';
import { SettingsService } from '../../services/settingsService';
@Component({
selector: 'friends-box',
templateUrl: 'friends-box.pug',
styleUrls: ['friends-box.scss'],
})
export class FriendsBox {
readonly friendsIcon = faUserFriends;
readonly cogIcon = faCog;
readonly addToPartyIcon = faUserPlus;
readonly userOptionsIcon = faUserCog;
readonly statusIcon = faCircle;
@Output() sendMessage = new EventEmitter<Friend>();
removing?: Friend;
constructor(private settings: SettingsService, private model: Model, private game: PonyTownGame) {
}
get friends() {
return this.model.friends;
}
get hidden() {
return !!this.settings.account.hidden;
}
toggleHidden() {
this.settings.account.hidden = !this.settings.account.hidden;
this.settings.saveAccountSettings(this.settings.account);
}
toggle() {
this.removing = undefined;
}
sendMessageTo(friend: Friend) {
this.sendMessage.emit(friend);
}
inviteToParty(friend: Friend) {
this.game.send(server => server.playerAction(friend.entityId, PlayerAction.InviteToParty, undefined));
}
remove(friend: Friend) {
this.removing = friend;
}
cancelRemove() {
this.removing = undefined;
}
confirmRemove() {
if (this.removing && this.model.friends) {
const { accountId } = this.removing;
this.game.send(server => server.actionParam(Action.RemoveFriend, accountId));
removeItem(this.model.friends, this.removing);
this.removing = undefined;
}
}
setStatus(status: string) {
this.settings.account.hidden = status === 'invisible';
this.settings.saveAccountSettings(this.settings.account);
}
}
@@ -0,0 +1,6 @@
.btn-group.d-flex(*ngIf="canInstall")
button.btn.btn-lg.btn-outline-success.text-wrap.flex-grow-1((click)="install()")
| Add #[b Pony Town] to {{isMobile ? 'home screen' : 'desktop'}}
button.btn.btn-lg.btn-outline-success.flex-grow-0(
(click)="dismiss()" title="Dismiss" [attr.aria-label]="'Dismiss add to ' + (isMobile ? 'home screen' : 'desktop')")
fa-icon([icon]="closeIcon")
@@ -0,0 +1,5 @@
@import '../../../../styles/partials/variables';
:host {
display: block;
}
@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { faTimes } from '../../../client/icons';
import { InstallService } from '../../services/installService';
import { isMobile } from '../../../client/data';
@Component({
selector: 'install-button',
templateUrl: 'install-button.pug',
styleUrls: ['install-button.scss'],
})
export class InstallButton {
readonly closeIcon = faTimes;
constructor(private installService: InstallService) {
}
get canInstall() {
return this.installService.canInstall;
}
get isMobile() {
return isMobile;
}
install() {
this.installService.install();
}
dismiss() {
this.installService.dismiss();
}
}
@@ -0,0 +1,22 @@
.modal-header.d-none.d-sm-block
h4.modal-title(labelledBy=".modal")
| Invites
.modal-body
p
| Using #[b {{invites.length}}] out of #[b {{inviteLimit}}] invites.
.list-group
.list-group-item(*ngFor="let i of invites" [class.text-muted]="!i.active")
.d-flex.justify-content-between
.d-flex.align-items-center
portrait-box([pony]="i.pony" size="small" [noBorder]="true" style="margin: -5px 10px -5px -10px;")
b {{i.name}}
span.text-muted.ml-1(*ngIf="!i.active") (inactive)
button.btn.btn-xs.btn-outline-danger Cancel
.alert.alert-danger.mt-3.mb-0(*ngIf="error")
| {{error}}
.modal-footer.justify-content-end
button.btn.btn-outline-secondary((click)="close.emit()")
| Close
@@ -0,0 +1,32 @@
import { Component, Output, EventEmitter, OnInit } from '@angular/core';
import { toPalette } from '../../../common/ponyInfo';
import { decompressPonyString } from '../../../common/compressPony';
import { PonyTownGame } from '../../../client/game';
import { Action, SupporterInvite, PalettePonyInfo } from '../../../common/interfaces';
import { removeItem } from '../../../common/utils';
import { Model } from '../../services/model';
@Component({
selector: 'invites-modal',
templateUrl: 'invites-modal.pug',
})
export class InvitesModal implements OnInit {
@Output() close = new EventEmitter();
invites: (SupporterInvite & { pony: PalettePonyInfo; })[] = [];
error?: string;
constructor(private model: Model, private game: PonyTownGame) {
}
get inviteLimit() {
return this.model.supporterInviteLimit;
}
ngOnInit() {
this.game.send(server => server.getInvites())!
.then(invites => invites.map(i => ({ ...i, pony: toPalette(decompressPonyString(i.info)) })))
.then(invites => this.invites = invites);
}
remove(invite: SupporterInvite) {
this.error = undefined;
this.game.send(server => server.actionParam(Action.CancelSupporterInvite, invite.id));
removeItem(this.invites, invite);
}
}
@@ -0,0 +1,3 @@
kbd([title]="title || ''" [attr.aria-label]="title")
span([attr.aria-hidden]="!!title")
ng-content
@@ -0,0 +1,9 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'kbd-key',
templateUrl: 'kbd-key.pug',
})
export class KbdKey {
@Input() title?: string;
}
@@ -0,0 +1,62 @@
nav.navbar.navbar-expand.justify-content-end(role="navigation")
.d-none.d-md-block
a.pixelart.logo(*ngIf="logo" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }")
img.pixelart.logo-large(revSrc="images/logo-large.png" alt="Pony Town")
img.pixelart.logo-small(revSrc="images/logo-small.png" alt="Pony Town")
.navbar-collapse.text-right
.navbar-nav.ml-auto
ng-content
.dropdown(*ngIf="account" #dropdown="ag-dropdown" dropdown autoClose="outsideClick")
button.btn.cursor-pointer.dropdown-toggle(dropdownToggle [attr.aria-label]="'Signed-in as ' + account.name")
fa-icon.mr-1(
*ngIf="hasSupporterIcon" [icon]="starIcon" [ngClass]="supporterClass"
[title]="supporterTitle" [attr.aria-label]="supporterTitle" aria-hidden="true")
.d-none.d-sm-inline(aria-hidden="true") {{account.name}}
fa-icon.d-inline-block.d-sm-none(
[icon]="userIcon" [title]="account.name" [fixedWidth]="true" size="lg" aria-hidden="true")
fa-icon.text-danger.account-alert-icon(
*ngIf="showAccountAlert" [icon]="alertIcon" [fixedWidth]="true" size="sm" aria-hidden="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
.dropdown-item.d-flex.align-items-center
.flex-grow-1.mr-3 Status:
.dropdown(dropdown style="width: 65px;")
button.online-offline.no-highlight.p-0.dropdown-toggle.no-arrow(dropdownToggle)
span.text-success(*ngIf="!hidden") online
span.text-muted(*ngIf="hidden") offline
fa-icon.ml-2([icon]="cogIcon")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
button.dropdown-item((click)="setStatus('online')")
fa-icon.mr-2.text-success([icon]="statusIcon" size="xs" [fixedWidth]="true")
| Online
button.dropdown-item((click)="setStatus('invisible')")
fa-icon.mr-2.text-muted([icon]="statusIcon" size="xs" [fixedWidth]="true")
| Show as Offline
a.dropdown-item(routerLink="/account" (click)="dropdown.close()" tabindex)
| Account settings
fa-icon.text-danger.ml-1(*ngIf="showAccountAlert" [icon]="alertIcon" aria-hidden="true")
.dropdown-divider
button.dropdown-item((click)="signOut.emit(); dropdown.close()")
| Sign out
.text-muted(*ngIf="loading" style="font-size: 20px; padding: 10px 20px;")
fa-icon([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
form.navbar-form.ml-2(*ngIf="!loading && !account")
.button-group.dropdown(dropdown)
button.btn.btn-default.dropdown-toggle(dropdownToggle [disabled]="!!loadingError")
| Sign in
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
.dropdown-header(*ngIf="signUpProviders.length")
| Sign in or sign up
button.dropdown-item(
*ngFor="let p of signUpProviders" (click)="signInTo(p)" title="Sign in using {{p.name}}")
fa-icon.mr-1([icon]="icon(p.id)" [fixedWidth]="true")
| {{p.name}}
.dropdown-header(*ngIf="signInProviders.length")
| Sign in only
button.dropdown-item.sign-in-only(
*ngFor="let p of signInProviders" (click)="signInTo(p)" title="Sign in using {{p.name}}")
fa-icon.mr-1([icon]="icon(p.id)" [fixedWidth]="true")
| {{p.name}}
@@ -0,0 +1,59 @@
@import '../../../../styles/partials/variables';
.navbar {
font-size: 16px;
margin-bottom: 0;
padding: 0px !important;
padding-top: $navbar-link-border !important;
.navbar-toggler {
margin: 8px 0;
}
}
.logo {
margin-top: 5px;
position: absolute;
top: 0;
left: 0;
&.active {
display: none;
}
}
.logo-large {
width: 287px;
display: none;
@include media-breakpoint-up(lg) {
display: block;
}
}
.logo-small {
width: 37px;
display: block;
@include media-breakpoint-up(lg) {
display: none;
}
}
.btn {
transition: none;
}
.sign-in-only {
opacity: 0.6;
}
.account-alert-icon {
position: absolute;
right: 20px;
top: 5px;
}
.online-offline {
font-weight: 500;
}
@@ -0,0 +1,61 @@
import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core';
import { AccountData, OAuthProvider } from '../../../common/interfaces';
import { signUpProviders, signInProviders } from '../../../client/data';
import { getProviderIcon } from '../sign-in-box/sign-in-box';
import { faStar, faSpinner, faUser, faExclamationCircle, faCog, faCircle } from '../../../client/icons';
import { Model } from '../../services/model';
import { supporterClass, supporterTitle, isSupporterOrPastSupporter } from '../../../client/clientUtils';
import { SettingsService } from '../../services/settingsService';
import { REQUEST_DATE_OF_BIRTH } from '../../../common/constants';
@Component({
selector: 'menu-bar',
templateUrl: 'menu-bar.pug',
styleUrls: ['menu-bar.scss'],
})
export class MenuBar {
readonly signUpProviders = signUpProviders;
readonly signInProviders = signInProviders;
readonly starIcon = faStar;
readonly spinnerIcon = faSpinner;
readonly userIcon = faUser;
readonly alertIcon = faExclamationCircle;
readonly cogIcon = faCog;
readonly statusIcon = faCircle;
@Input() logo = false;
@Input() loading = false;
@Input() loadingError = false;
@Input() account?: AccountData;
@Output() signOut = new EventEmitter();
@Output() signIn = new EventEmitter<OAuthProvider>();
constructor(private model: Model, private settings: SettingsService) {
}
get hasSupporterIcon() {
return isSupporterOrPastSupporter(this.account);
}
get supporterTitle() {
return supporterTitle(this.account);
}
get supporterClass() {
return supporterClass(this.account);
}
get showAccountAlert() {
return this.model.missingBirthdate && REQUEST_DATE_OF_BIRTH;
}
get hidden() {
return !!this.settings.account.hidden;
}
icon(id: string) {
return getProviderIcon(id);
}
signInTo(provider: OAuthProvider) {
this.signIn.emit(provider);
}
@HostListener('window:resize')
resize() {
}
setStatus(status: string) {
this.settings.account.hidden = status === 'invisible';
this.settings.saveAccountSettings(this.settings.account);
}
}
@@ -0,0 +1,5 @@
.navbar-link(routerLinkActive="active" [routerLinkActiveOptions]="{ exact: route === '/' }")
a([routerLink]="route" tabindex linkCurrent)
.d-none.d-sm-inline {{name}}
fa-icon.d-inline-block.d-sm-none(
[icon]="icon" [title]="name" [attr.aria-label]="name" [fixedWidth]="true" size="lg")
@@ -0,0 +1,26 @@
@import '../../../../styles/partials/variables';
.navbar-link {
border-top: solid $navbar-link-border transparent;
margin-top: -$navbar-link-border;
margin-right: 5px;
&.active {
text-decoration: none;
border-top-color: $navbar-dark-active-color;
border-right-color: $navbar-dark-active-color;
}
&:hover, &:active, &:link {
text-decoration: none;
border-top-color: $navbar-dark-active-color;
border-right-color: $navbar-dark-active-color;
}
> a {
@media (max-width: 360px) {
padding-left: 10px;
padding-right: 10px;
}
}
}
@@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
import { emptyIcon } from '../../../client/icons';
@Component({
selector: 'menu-item',
templateUrl: 'menu-item.pug',
styleUrls: ['menu-item.scss'],
})
export class MenuItem {
@Input() route: any;
@Input() name?: string;
@Input() icon = emptyIcon;
}
@@ -0,0 +1,72 @@
ng-template(#notePopover)
textarea.form-control.pony-mod-note-editor(
cols="20" rows="8" agAutoFocus [(ngModel)]="note" (keydown.escape)="blur()" (blur)="blur()")
ng-template(#tooltip)
.text-left.text-pre
div(*ngIf="hasCounters")
span.text-muted spam:
span.ml-1 {{counters?.spam || 0}}
span.text-muted.ml-1 swearing:
span.ml-1 {{counters?.swears || 0}}
span.text-muted.ml-1 timeouts:
span.ml-1 {{counters?.timeouts || 0}}
div {{note}}
.btn-group.btn-group-xs.btn-group-shadow(*ngIf="check?.xcz?.vdw?.qwe?.mnb")
.btn-group.dropdown(dropdown)
button.btn.btn-xs.btn-default(dropdownToggle)
fa-icon([icon]="moreIcon")
.dropdown-menu(*dropdownMenu)
a.dropdown-item((click)="report()")
fa-icon.mr-1([icon]="flagIcon" [fixedWidth]="true")
| Report
h6.dropdown-header Other options
a.dropdown-item(*ngFor="let a of check.actions" (click)="modAction(a.action)")
fa-icon.mr-1.text-danger([icon]="dangerIcon" [fixedWidth]="true")
| {{a.name}}
.btn-group(
[tooltip]="isNoteOpen ? null : tooltip" [isDisabled]="!note && !hasCounters"
placement="bottom" containerClass="tooltip-notes")
button.btn.btn-xs(
[popover]="notePopover"
placement="bottom"
[isOpen]="isNoteOpen"
[btnHighlightDanger]="!!note"
[class.pointer-none]="isNoteOpen"
(onShown)="isNoteOpen = true"
(onHidden)="blur()"
container="body")
fa-icon([icon]="noteIcon")
.btn-group.dropdown(dropdown [tooltip]="muteTooltip")
button.btn.btn-xs(dropdownToggle [ngClass]="className(mute)")
fa-icon([icon]="muteIcon")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="setMute(0)")
| clear #[b mute]
a.dropdown-item((click)="setMute(-1)")
| perma #[b mute]
.dropdown-divider
a.dropdown-item(*ngFor="let t of timeouts" (click)="setMute(t.value)")
| {{t.label}}
.btn-group.dropdown(dropdown [tooltip]="shadowTooltip")
button.btn.btn-xs(dropdownToggle [ngClass]="className(shadow)")
fa-icon([icon]="hideIcon")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="setShadow(0)")
| clear #[b shadow]
a.dropdown-item((click)="setShadow(-1)")
| perma #[b shadow]
.dropdown-divider
a.dropdown-item(*ngFor="let t of timeouts" (click)="setShadow(t.value)")
| {{t.label}}
.pony-mod-age([title]="ageTitle")
| {{ageLabel}}
.pony-mod-account(*ngIf="account")
| {{account}}
span.text-smuted.ml-1 [{{country || '??'}}]
@@ -0,0 +1,38 @@
@import '../../../../styles/partials/variables';
.pony-mod-account {
position: absolute;
left: 2px;
bottom: -22px;
font-weight: bold;
white-space: nowrap;
color: white;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
max-width: none;
}
}
.pony-mod-age {
position: absolute;
left: -25px;
width: 20px;
text-align: center;
font-weight: bold;
color: $primary;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
}
.pony-mod-note-editor {
color: #333;
background: none;
margin: -9px -14px;
width: 250px;
max-width: 250px;
max-height: 140px;
font-size: smaller;
}
@@ -0,0 +1,97 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { ModAction, Pony } from '../../../common/interfaces';
import { TIMEOUTS } from '../../../common/constants';
import { Model } from '../../services/model';
import { PonyTownGame } from '../../../client/game';
import { faFlag, faStickyNote, faMicrophoneSlash, faEyeSlash, faUserCog, faExclamationCircle } from '../../../client/icons';
const ageLabels = ['', 'M', 'A', '', '', '[M]', '[A]'];
const ageTitles = ['Not set', 'Minor', 'Adult', '', '', 'Minor (locked)', 'Adult (locked)'];
@Component({
selector: 'mod-box',
templateUrl: 'mod-box.pug',
styleUrls: ['mod-box.scss'],
})
export class ModBox implements OnDestroy {
readonly flagIcon = faFlag;
readonly noteIcon = faStickyNote;
readonly muteIcon = faMicrophoneSlash;
readonly hideIcon = faEyeSlash;
readonly moreIcon = faUserCog;
readonly dangerIcon = faExclamationCircle;
readonly timeouts = TIMEOUTS;
@Input() pony!: Pony;
isNoteOpen = false;
constructor(private model: Model, private game: PonyTownGame) {
}
get ageLabel() {
return ageLabels[this.modInfo && this.modInfo.age || 0];
}
get ageTitle() {
return ageTitles[this.modInfo && this.modInfo.age || 0];
}
get modInfo() {
return this.pony.modInfo;
}
get account() {
return this.modInfo && this.modInfo.account;
}
get country() {
return this.modInfo && this.modInfo.country;
}
get mute() {
return this.modInfo && this.modInfo.mute;
}
get muteTooltip() {
return this.mute ? (this.mute === 'perma' ? 'Permanently Muted' : `Muted for ${this.mute}`) : 'Mute';
}
get shadow() {
return this.modInfo && this.modInfo.shadow;
}
get shadowTooltip() {
return this.shadow ? (this.shadow === 'perma' ? 'Permanently Shadowed' : `Shadowed for ${this.shadow}`) : 'Shadow';
}
get counters() {
return this.modInfo && this.modInfo.counters;
}
get hasCounters() {
const counters = this.counters;
return counters && (counters.spam || counters.swears || counters.timeouts);
}
get check() {
return this.model.modCheck;
}
get note() {
return this.modInfo && this.modInfo.note;
}
set note(value) {
if (this.modInfo) {
this.modInfo.note = value;
}
}
ngOnDestroy() {
if (this.isNoteOpen) {
this.blur();
}
}
className(value: string) {
return value ? (value === 'perma' ? 'btn-danger' : 'btn-warning') : 'btn-default';
}
report() {
this.modAction(ModAction.Report);
}
setMute(value: number) {
this.modAction(ModAction.Mute, value);
}
setShadow(value: number) {
this.modAction(ModAction.Shadow, value);
}
blur() {
this.game.send(server => server.setNote(this.pony.id, this.modInfo && this.modInfo.note || ''));
this.isNoteOpen = false;
}
modAction(type: ModAction, param = 0) {
return this.game.send(server => server.otherAction(this.pony.id, type, param));
}
}
@@ -0,0 +1,20 @@
ng-template(#popover)
.notification-popover
p([innerHTML]="notification.message")
.small.text-muted.mb-1(*ngIf="notification.note")
| {{notification.note}}
div
button.btn.btn-xs.btn-outline-danger.float-right(
*ngIf="ignoreButton" (click)="ignore()" tooltip="Ignore this player")
fa-icon([icon]="banIcon")
.notification-buttons
button.btn.btn-xs.btn-success(*ngIf="okButton" (click)="accept()") ok
button.btn.btn-xs.btn-success(*ngIf="yesButton" (click)="accept()") yes
button.btn.btn-xs.btn-success(*ngIf="acceptButton" (click)="accept()") accept
button.btn.btn-xs.btn-danger(*ngIf="noButton" (click)="reject()") no
button.btn.btn-xs.btn-danger(*ngIf="rejectButton" (click)="reject()") reject
.notification.cursor-pointer(
[popover]="popover" [isOpen]="isOpen" (contextmenu)="reject(); $event.preventDefault()"
placement="left" containerClass="popover-notification")
portrait-box([pony]="paletteInfo" [flip]="true" size="small")
@@ -0,0 +1,3 @@
.notification {
padding: 5px;
}
@@ -0,0 +1,68 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { PlayerAction, Notification, NotificationFlags, EntityPlayerState } from '../../../common/interfaces';
import { PonyTownGame } from '../../../client/game';
import { hasFlag, setFlag } from '../../../common/utils';
import { faBan } from '../../../client/icons';
import { getPaletteInfo } from '../../../common/pony';
@Component({
selector: 'notification-item',
templateUrl: 'notification-item.pug',
styleUrls: ['notification-item.scss'],
})
export class NotificationItem implements OnDestroy {
readonly banIcon = faBan;
@Input() notification!: Notification;
constructor(private game: PonyTownGame) {
}
get isOpen() {
return this.notification.open;
}
set isOpen(value: boolean) {
if (value) {
this.game.notifications.forEach(n => n.open = false);
}
this.notification.open = value;
}
get okButton() {
return hasFlag(this.notification.flags, NotificationFlags.Ok);
}
get yesButton() {
return hasFlag(this.notification.flags, NotificationFlags.Yes);
}
get acceptButton() {
return hasFlag(this.notification.flags, NotificationFlags.Accept);
}
get noButton() {
return hasFlag(this.notification.flags, NotificationFlags.No);
}
get rejectButton() {
return hasFlag(this.notification.flags, NotificationFlags.Reject);
}
get ignoreButton() {
return hasFlag(this.notification.flags, NotificationFlags.Ignore);
}
get paletteInfo() {
return getPaletteInfo(this.notification.pony);
}
ngOnDestroy() {
this.isOpen = false;
}
accept() {
this.game.send(server => server.acceptNotification(this.notification.id));
}
reject() {
this.game.send(server => server.rejectNotification(this.notification.id));
}
ignore() {
this.reject();
const pony = this.notification.pony;
if (pony !== this.game.player) {
this.game.send(server => server.playerAction(pony.id, PlayerAction.Ignore, undefined));
pony.playerState = setFlag(pony.playerState, EntityPlayerState.Ignored, true);
}
}
}
@@ -0,0 +1,7 @@
.notification-list
.game-button.notification-ellipsis(*ngIf="start" (click)="prev()")
fa-icon([icon]="ellipsisIcon")
notification-item(
*ngFor="let n of notifications | slice:start:limit" [notification]="n" [class.elastic-from-right]="n.fresh")
.game-button.notification-ellipsis(*ngIf="hasMore" (click)="next()")
fa-icon([icon]="ellipsisIcon")
@@ -0,0 +1,4 @@
.notification-ellipsis {
text-align: center;
margin-right: -2px;
}
@@ -0,0 +1,33 @@
import { Component, Input } from '@angular/core';
import { Notification } from '../../../common/interfaces';
import { faEllipsisV } from '../../../client/icons';
const LIMIT = 8;
@Component({
selector: 'notification-list',
templateUrl: 'notification-list.pug',
styleUrls: ['notification-list.scss'],
})
export class NotificationList {
readonly ellipsisIcon = faEllipsisV;
@Input() notifications!: Notification[];
@Input() set notificationsLength(value: number) {
while (this.start > value) {
this.prev();
}
}
start = 0;
get limit() {
return this.start + LIMIT;
}
get hasMore() {
return this.notifications.length > (this.start + this.limit);
}
next() {
this.start += this.limit;
}
prev() {
this.start -= this.start <= LIMIT ? LIMIT : LIMIT - 1;
}
}
@@ -0,0 +1,18 @@
.page-loader.text-muted.text-center.text-large(*ngIf="loading || updating")
fa-icon([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
div(*ngIf="updating")
p.page-updating.text-muted Updating...
p.text-unsafe(*ngIf="updatingTakesLongTime")
| Updating is taking longer than expected, #[button.btn.btn-outline-danger((click)="reload()") restart]
div(*ngIf="loadingError && !updating")
div([ngSwitch]="loadingError")
p.text-unsafe(*ngSwitchCase="'request-limit'")
| Server is under heavy load, please wait...
p.text-unsafe(*ngSwitchCase="'cannot-connect'")
| Cannot connect to the server, retrying...
p.text-unsafe(*ngSwitchCase="'cloudflare-error'")
| Cloudflare protection error, #[button.btn.btn-outline-danger((click)="reload()") reload] to continue.
p.text-unsafe(*ngSwitchDefault)
| Unexpected error occurred, #[button.btn.btn-outline-danger((click)="reload()") reload] to continue.
@@ -0,0 +1,20 @@
.page-loader {
padding: 150px 0;
}
fa-icon {
font-size: 50px;
}
p {
margin-top: 30px;
}
.page-updating {
font-size: 20px;
}
.btn {
vertical-align: baseline;
font-weight: bold;
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { faSpinner } from '../../../client/icons';
import { Model } from '../../services/model';
import { hardReload } from '../../../client/clientUtils';
@Component({
selector: 'page-loader',
templateUrl: 'page-loader.pug',
styleUrls: ['page-loader.scss'],
})
export class PageLoader {
readonly spinnerIcon = faSpinner;
constructor(private model: Model) {
}
get loading() {
return this.model.loading;
}
get updating() {
return this.model.updating;
}
get updatingTakesLongTime() {
return this.model.updatingTakesLongTime;
}
get loadingError() {
return this.model.loadingError;
}
reload() {
hardReload();
}
}
@@ -0,0 +1,6 @@
.party-box([class.pending]="member.pending" [class.offline]="member.offline" (window:resize)="true")
portrait-box.cursor-pointer([pony]="paletteInfo" size="small" (click)="click()")
.party-box-icon.party-box-leader.pointer-none(*ngIf="member.leader" title="Party leader")
fa-icon([icon]="leaderIcon" size="xs")
.party-box-icon.party-box-offline.pointer-none(*ngIf="member.offline" title="Offline")
fa-icon([icon]="offlineIcon" size="sm")
@@ -0,0 +1,33 @@
.party-box {
margin-bottom: 10px;
position: relative;
&.pending {
opacity: 0.5;
}
&.offline {
opacity: 0.7;
filter: grayscale(100%);
}
}
.party-box-icon {
position: absolute;
text-shadow: 0 0 5px #000;
font-size: 18px;
> fa-icon {
filter: drop-shadow(0 0 5px #000);
}
}
.party-box-leader {
left: -5px;
top: -14px;
}
.party-box-offline {
left: -2px;
bottom: -8px;
}
@@ -0,0 +1,24 @@
import { Component, Input } from '@angular/core';
import { PartyMember } from '../../../common/interfaces';
import { PonyTownGame } from '../../../client/game';
import { partyLeaderIcon, offlineIcon } from '../../../client/icons';
import { getPaletteInfo } from '../../../common/pony';
@Component({
selector: 'party-box',
templateUrl: 'party-box.pug',
styleUrls: ['party-box.scss'],
})
export class PartyBox {
readonly leaderIcon = partyLeaderIcon;
readonly offlineIcon = offlineIcon;
@Input() member!: PartyMember;
constructor(private game: PonyTownGame) {
}
get paletteInfo() {
return this.member.pony && getPaletteInfo(this.member.pony);
}
click() {
this.game.select(this.member.pony);
}
}
@@ -0,0 +1,20 @@
.party-list.unselectable(*ngIf="hasParty")
.dropdown(dropdown [hookToCanvas]="true")
a.game-button.party-list-options(dropdownToggle)
fa-icon(*ngIf="!isLeader" [icon]="cogIcon" [fixedWidth]="true" title="Party options")
fa-icon(*ngIf="isLeader" [icon]="leaderIcon" [fixedWidth]="true" title="You are party leader")
.dropdown-menu(*dropdownMenu)
.dropdown-header(*ngIf="isLeader")
| You are party leader
a.dropdown-item((click)="leave()")
| Leave party
a.dropdown-item((click)="hidden = !hidden")
| {{hidden ? 'Show party' : 'Hide party'}}
.party-list-items.pt-1(*ngIf="!hidden")
.game-button.party-list-ellipsis.mb-1(*ngIf="start" (click)="prev()")
fa-icon([icon]="ellipsisIcon")
div(*ngFor="let m of members | slice:start:limit")
party-box([member]="m")
.game-button.party-list-ellipsis(*ngIf="hasMore" (click)="next()" style="margin-top: -5px")
fa-icon([icon]="ellipsisIcon")
@@ -0,0 +1,18 @@
.party-list {
position: relative;
@media (max-height: 180px) {
display: none;
}
}
.party-list-ellipsis {
margin-top: -4px;
margin-left: -3px;
}
.party-list-items {
@media (max-height: 260px) {
display: none;
}
}
@@ -0,0 +1,93 @@
import { Component, HostListener, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { PartyMember } from '../../../common/interfaces';
import { PARTY_LIMIT } from '../../../common/constants';
import { PonyTownGame } from '../../../client/game';
import { partyLeaderIcon, faCog, faEllipsisV } from '../../../client/icons';
import { clamp } from '../../../common/utils';
import { isPartyLeader } from '../../../client/partyUtils';
function visibleMembers(members: PartyMember[], max: number, start: number) {
return members.length > max ? Math.max(max - (start > 0 ? 2 : 1), 1) : max;
}
@Component({
selector: 'party-list',
templateUrl: 'party-list.pug',
styleUrls: ['party-list.scss'],
})
export class PartyList implements OnInit, OnDestroy {
readonly ellipsisIcon = faEllipsisV;
readonly leaderIcon = partyLeaderIcon;
readonly cogIcon = faCog;
hidden = false;
start = 0;
maxMembers = PARTY_LIMIT - 1;
members: PartyMember[] = [];
private subscription?: Subscription;
constructor(private game: PonyTownGame) {
}
get hasParty() {
return this.game.party !== undefined;
}
get isLeader() {
return isPartyLeader(this.game);
}
get hasMore() {
return this.members.length > (this.start + this.visible);
}
get visible() {
return visibleMembers(this.members, this.maxMembers, this.start);
}
get limit() {
return this.start + this.visible;
}
ngOnInit() {
this.subscription = this.game.onPartyUpdate.subscribe(() => this.update());
this.resized();
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
isMe(member: PartyMember) {
return this.game.player && this.game.player.id === member.id;
}
leave() {
this.game.send(server => server.leaveParty());
}
update() {
if (this.hasParty) {
this.members = this.game.party ? this.game.party.members.filter(m => !m.self) : [];
while (this.start > 0 && this.members.length <= this.start) {
this.start = 0;
}
} else {
this.members = [];
this.start = 0;
}
}
@HostListener('window:resize')
resized() {
const padding = 140 + 110;
const max = clamp(Math.floor((window.innerHeight - padding) / 43), 0, PARTY_LIMIT - 1);
if (this.maxMembers !== max) {
this.start = 0;
this.maxMembers = max;
}
}
next() {
this.start += this.visible;
}
prev() {
const max = this.members.length - 1;
let start = 0;
while (start < max && (start + visibleMembers(this.members, this.maxMembers, start)) !== this.start) {
start++;
}
this.start = start;
}
}
@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'siteName',
})
export class SiteNamePipe implements PipeTransform {
transform(value: string | undefined) {
const match = String(value || '').match(/(\w+)\.com/);
return match && match[1];
}
}
@@ -0,0 +1,121 @@
.form-group(*ngIf="hasTooManyPonies")
.alert.alert-warning(role="alert")
| You have more than the allowed number of {{characterLimit}} ponies, the additional ones may get deleted.
| Remove some of your unused ponies to prevent losing the ones you want to keep.
.form-group(*ngIf="isMarkedForMultiples")
.alert.alert-warning(role="alert")
| Your account has been flagged for creating multiple accounts.
| Creating more accounts may result in a permanent ban.
.form-group(*ngIf="accountAlert")
.alert.alert-warning(role="alert")
| {{accountAlert}}
.form-group(*ngIf="leftMessage")
.alert.alert-warning(role="alert")
| {{leftMessage}}
.form-group(*ngIf="isAndroidBrowser")
.alert.alert-warning(role="alert")
| Your browser is outdated and is not able to correctly run Pony Town.
| Please install different browser to be able to play the game.
.form-group(*ngIf="model.missingBirthdate && !birthdateSet && requestBirthdate")
.alert.alert-warning.text-left(role="alert")
label(for="birthdate")
| Set your date of birth
date-picker([(date)]="birthdate")
.mt-2.text-right
button.btn.btn-default.px-4((click)="saveBirthdate()") Save
p.mb-0.mt-2
| Please fill-in your #[b date of birth] in order to not lose access to the game in future updates.
.form-group.dropdown(dropdown)
.btn-group.d-flex
button.btn.btn-lg.btn-success.text-ellipsis.flex-grow-1(
#playButton (click)="joining ? cancel() : play()" [disabled]="!canPlay && !joining")
div(*ngIf="joining")
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
| Cancel
div(*ngIf="!joining && server")
| #[strong {{label || 'Play'}}] on #[span {{server.name}}]
.text-faded(*ngIf="!joining && !server")
| select server to play
button.btn.btn-lg.btn-success.flex-grow-0.dropdown-toggle(
dropdownToggle aria-label="select server" [disabled]="joining" style="position: relative")
.dropdown-menu.w-100(*dropdownMenu style="overflow: hidden")
button.dropdown-item(*ngFor="let s of servers" (click)="server = s; playButton.focus()")
div
.float-right(style="position: relative;")
.text-unsafe(*ngIf="s.offline") #[span.sr-only server] offline
.text-muted(*ngIf="!s.offline") online #[span.sr-only players] ({{s.online}})
.flag.mr-2(*ngFor="let f of s.countryFlags" [ngClass]="'flag-' + f")
fa-icon.text-muted.mr-2(*ngIf="!hasFlag(s)" [icon]="getIcon(s)" size="lg")
strong {{s.name}}
.text-muted.text-wrap {{s.desc}}
.form-group.text-left.text-muted.server-alert(*ngIf="server?.alert === '18+'")
fa-icon.float-left.p-2([icon]="warningIcon" size="2x")
| By playing on this server you confirm that you are over 18 years old and you take no issue
| with seeing adult topics.
.form-group.text-left.text-info.server-alert(*ngIf="server?.alert === 'test'")
fa-icon.float-left.p-2([icon]="infoIcon" size="2x")
| Supporter test server: Here you can try experimental and unfinished features that we're working on.
| Keep in mind everything is subject to change.
.form-group(*ngIf="server?.offline")
.alert.alert-info(role="alert")
| Selected server is offline, try again later
.form-group(*ngIf="offline")
.alert.alert-info(role="alert")
| Server is offline, try again later
.form-group(*ngIf="protectionError && !offline")
.alert.alert-info(role="alert")
| Cloudflare error, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to continue.
.form-group(*ngIf="updateWarning")
.alert.alert-warning(role="alert")
| Server will restart shortly for updates and maintenance.
| Save your character to avoid losing any progress.
.form-group(*ngIf="isBrowserOutdated")
.alert.alert-warning(role="alert")
button.close.float-right((click)="dismissOutdatedBrowser()" aria-label="Close" style="font-size: 20px;") &times;
| Your browser is outdated and is known to have issues running Pony Town.
| Make sure you have latest version installed.
.form-group(*ngIf="invalidVersion && !offline")
.alert.alert-danger(role="alert")
| Your client version is outdated, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to be able to play.
.form-group(*ngIf="isAccessError")
.alert.alert-danger(role="alert")
| You're no longer signed-in, #[button.btn.btn-sm.btn-outline-default((click)="reload()") reload] to be able to sign-in again.
.form-group(*ngIf="failedToLoadImages")
.alert.alert-danger(role="alert")
| Failed to load game assets, #[button.btn.btn-sm.btn-outline-default((click)="hardReload()") reload] to retry.
.form-group(*ngIf="isWebGLError")
.alert.alert-danger(role="alert")
| Failed to create WebGL context. Your graphics card drivers or browser are outdated or graphics
| acceleration is disabled. Go to #[a.alert-link(href="http://webglreport.com/" tabindex) WebGL Report] to
| check WebGL support in your browser.
.form-group(*ngIf="isBrowserError")
.alert.alert-danger(role="alert")
| Your browser is outdated, make sure you have the latest version installed.
.form-group(*ngIf="isOtherError")
.alert.alert-danger(role="alert")
| {{error}}
.form-group.text-left.text-large(*ngIf="server")
h5 Server rules
p.text-muted.list-rules
| {{server.desc}}
@@ -0,0 +1,26 @@
@import '../../../../styles/partials/variables';
.btn-group.d-flex {
display: flex;
> .btn.flex-grow {
flex-grow: 1;
}
}
.dropdown-toggle {
display: flex;
align-items: center;
justify-content: center;
}
.server-alert {
display: flex;
align-items: center;
font-size: 13px;
}
.alert .btn {
vertical-align: baseline;
font-weight: bold;
}
@@ -0,0 +1,183 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { ServerInfo, AccountDataFlags } from '../../../common/interfaces';
import { RequestError, delay, includes, hasFlag } from '../../../common/utils';
import {
WEBGL_CREATION_ERROR, ACCESS_ERROR, ACCOUNT_ERROR, BROWSER_NOT_SUPPORTED_ERROR, NAME_ERROR, VERSION_ERROR,
OFFLINE_ERROR, PROTECTION_ERROR, NOT_AUTHENTICATED_ERROR, CHARACTER_LIMIT_ERROR
} from '../../../common/errors';
import { version } from '../../../client/data';
import { GameService } from '../../services/gameService';
import { Model } from '../../services/model';
import { faSpinner, faExclamationCircle, faInfoCircle, faGlobe, faStar, faWrench } from '../../../client/icons';
import { isBrowserOutdated, hardReload, isAndroidBrowser } from '../../../client/clientUtils';
import { loadAndInitSpriteSheets } from '../../../client/spriteUtils';
import { StorageService } from '../../services/storageService';
import { ErrorReporter } from '../../services/errorReporter';
import { REQUEST_DATE_OF_BIRTH } from '../../../common/constants';
const ignoredErrors = [
WEBGL_CREATION_ERROR,
BROWSER_NOT_SUPPORTED_ERROR,
NAME_ERROR,
OFFLINE_ERROR,
VERSION_ERROR,
ACCESS_ERROR,
PROTECTION_ERROR,
NOT_AUTHENTICATED_ERROR,
CHARACTER_LIMIT_ERROR,
'Saving in progress',
];
@Component({
selector: 'play-box',
templateUrl: 'play-box.pug',
styleUrls: ['play-box.scss'],
})
export class PlayBox implements OnInit {
readonly spinnerIcon = faSpinner;
readonly warningIcon = faExclamationCircle;
readonly infoIcon = faInfoCircle;
readonly requestBirthdate = REQUEST_DATE_OF_BIRTH;
@Output() errorChange = new EventEmitter<string | undefined>();
@Input() label?: string;
joining = false;
failedToLoadImages = false;
birthdate = '';
birthdateSet = false;
private locked = false;
constructor(
public gameService: GameService,
public model: Model,
private storage: StorageService,
private errorReporter: ErrorReporter,
) {
}
@Input()
get error() {
return this.gameService.error;
}
set error(value: string | undefined) {
if (this.gameService) {
this.gameService.error = value;
this.errorChange.emit(value);
}
}
get server() {
return this.gameService.server;
}
set server(value: ServerInfo | undefined) {
this.gameService.server = value;
}
get servers() {
return this.gameService.servers;
}
get offline(): boolean {
return this.gameService.offline;
}
get updateWarning() {
return this.gameService.updateWarning;
}
get invalidVersion(): boolean {
return !!(this.gameService.versionError || this.error === VERSION_ERROR
|| (this.gameService.version && this.gameService.version !== version));
}
get protectionError(): boolean {
return this.gameService.protectionError || this.error === PROTECTION_ERROR;
}
get canPlay(): boolean {
return !!this.server && this.gameService.canPlay && !this.locked && !this.invalidVersion && !this.failedToLoadImages;
}
get isAccessError(): boolean {
return this.error === ACCESS_ERROR || this.error === ACCOUNT_ERROR;
}
get isWebGLError(): boolean {
return this.error === WEBGL_CREATION_ERROR;
}
get isBrowserError(): boolean {
return this.error === BROWSER_NOT_SUPPORTED_ERROR;
}
get isOtherError(): boolean {
return !!this.error && !this.invalidVersion && !this.isAccessError && !this.isWebGLError && !this.isBrowserError;
}
get ponyLimit() {
return this.model.characterLimit;
}
get hasTooManyPonies(): boolean {
return this.model.ponies.length > this.ponyLimit;
}
get isMarkedForMultiples(): boolean {
const account = this.model.account;
return !!account && hasFlag(account.flags, AccountDataFlags.Duplicates);
}
get isAndroidBrowser() {
return isAndroidBrowser;
}
get isBrowserOutdated() {
return !isAndroidBrowser && isBrowserOutdated && !this.storage.getBoolean('dismiss-outdated-browser');
}
get leftMessage() {
return this.gameService.leftMessage;
}
get accountAlert() {
return this.model.accountAlert;
}
ngOnInit() {
loadAndInitSpriteSheets()
.then(loaded => this.failedToLoadImages = !loaded);
}
play() {
if (this.canPlay) {
this.joining = true;
this.locked = true;
this.error = undefined;
const delayTime = (!DEVELOPMENT && this.gameService.wasPlaying) ? 1500 : 10;
delay(delayTime) // delay joing if user reloaded the game instead of leaving cleanly
.then(() => this.model.savePony(this.model.pony))
.then(pony => this.joining ? this.gameService.join(pony.id) : Promise.resolve())
.catch((e: RequestError) => {
if (!/^Cancelled/.test(e.message)) {
this.error = e.message;
if (!includes(ignoredErrors, e.message) && !/shader/.test(e.message)) {
this.errorReporter.reportError(e, { status: e.status, text: e.text });
}
DEVELOPMENT && console.error(e);
}
})
.finally(() => this.joining = false)
.then(() => delay(1500))
.finally(() => this.locked = false);
}
}
cancel() {
this.gameService.leave('Cancelled joining');
}
reload() {
location.reload(true);
}
hardReload() {
hardReload();
}
hasFlag(server: ServerInfo) {
return server.countryFlags && server.countryFlags.length;
}
getIcon(server: ServerInfo) {
switch (server.flag) {
case 'star': return faStar;
case 'test': return faWrench;
default: return faGlobe;
}
}
dismissOutdatedBrowser() {
this.storage.setBoolean('dismiss-outdated-browser', true);
}
saveBirthdate() {
if (this.birthdate) {
this.model.updateAccount({ birthdate: this.birthdate });
this.birthdateSet = true;
}
}
}
@@ -0,0 +1,4 @@
.mx-auto.text-left.text-large(style="max-width: 400px;")
h5 General rules
ul.text-muted.list-rules
li(*ngFor="let r of rules") {{r}}
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { supporterLink } from '../../../client/data';
import { GENERAL_RULES } from '../../../common/constants';
@Component({
selector: 'play-notice',
templateUrl: 'play-notice.pug',
})
export class PlayNotice {
readonly patreonLink = supporterLink;
readonly rules = GENERAL_RULES;
}
@@ -0,0 +1,63 @@
.pony-box(*ngIf="pony")
.pony-box-tags
.pony-box-tag(*ngIf="special" [ngClass]="specialClass")
| {{special}}
.pony-box-tag.tag-friend(*ngIf="isFriend(pony)")
| friend
.pony-box-rect
.pony-box-name
| {{pony.name}}
.pony-box-buttons
site-info([site]="pony.site")
.pony-box-buttons-box
.btn-group.btn-group-shadow.dropdown(dropdown (isOpenChange)="removingFriend = false")
button.btn.btn-xs.dropdown-toggle(dropdownToggle [ngClass]="ignoredOrHidden ? 'btn-danger' : 'btn-default'")
fa-icon([icon]="cogIcon")
.dropdown-menu(*dropdownMenu)
button.dropdown-item((click)="toggleIgnore()")
fa-icon.mr-2([icon]="ignoreIcon" [fixedWidth]="true")
| {{isIgnored(pony) ? 'Unignore player' : 'Ignore player'}}
fa-icon.ml-1(*ngIf="isIgnored(pony)" [icon]="checkIcon")
button.dropdown-item((click)="hidePlayer(1)")
fa-icon.mr-2([icon]="hideIcon" [fixedWidth]="true")
| Hide player #[span.text-muted (24 hours)]
button.dropdown-item((click)="hidePlayer(0)")
fa-icon.mr-2([icon]="hideIcon" [fixedWidth]="true")
| Hide player #[span.text-muted (permanent)]
.dropdown-divider
button.dropdown-item((click)="sendMessageTo()")
fa-icon.mr-2([icon]="messageIcon" [fixedWidth]="true")
| Send whisper
button.dropdown-item(*ngIf="!isFriend(pony)" (click)="addFriend()")
fa-icon.mr-2.text-success([icon]="addFriendIcon" [fixedWidth]="true")
| Send friend request
div(*ngIf="isFriend(pony)" style="position: relative;")
button.dropdown-item((click)="removingFriend = true; $event.stopPropagation()")
fa-icon.mr-2.text-danger([icon]="removeFriendIcon" [fixedWidth]="true")
| Remove from friends
.remove-confirmation.px-2.d-flex.justify-content-center(*ngIf="removingFriend")
button.btn.btn-sm.btn-outline-success.flex-grow-1((click)="removingFriend = false; $event.stopPropagation()")
| Cancel
button.btn.btn-sm.btn-outline-danger.flex-grow-1.ml-1((click)="removeFriend()")
| Confirm remove
div(*ngIf="canInviteToSupporterServers")
h6.dropdown-header Supporter options
button.dropdown-item((click)="inviteToSupporterServers()")
fa-icon.mr-2.text-success([icon]="starIcon" [fixedWidth]="true")
| Invite to supporter servers
fa-icon.ml-1(*ngIf="isInvitedToSupporterServers" [icon]="checkIcon")
.btn-group.btn-group-shadow(*ngIf="canInviteToParty || canRemoveFromParty || canPromoteToLeader")
button.btn.btn-xs.btn-default(*ngIf="canInviteToParty" (click)="inviteToParty()" tooltip="Invite to party")
fa-icon([icon]="inviteIcon")
button.btn.btn-xs.btn-default(*ngIf="canRemoveFromParty" (click)="removeFromParty()" tooltip="Remove from party")
fa-icon([icon]="removeIcon")
button.btn.btn-xs.btn-default(*ngIf="canPromoteToLeader" (click)="promoteToLeader()" tooltip="Promote to leader")
fa-icon([icon]="leaderIcon")
mod-box(*ngIf="isMod" [pony]="pony")
portrait-box.pony-box-avatar([pony]="paletteInfo")
@@ -0,0 +1,113 @@
@import '../../../../styles/partials/variables';
.pony-box {
color: #333;
position: relative;
width: 100px;
height: 100px;
}
.pony-box-tags {
position: absolute;
top: -1px;
left: 50px;
display: flex;
}
.pony-box-tag {
background: #777;
text-transform: uppercase;
color: white;
font-size: 11px;
font-weight: bold;
line-height: 14px;
padding: 1px 10px 2px 10px;
border-top-right-radius: 5px;
border-top-left-radius: 5px;
margin-left: 5px;
white-space: nowrap;
&:first-child {
border-top-left-radius: 0;
padding-left: 40px;
margin-left: 0;
}
&.tag-dev {
background: $dev;
}
&.tag-mod {
background: $mod;
}
&.tag-sup1 {
background: $supporter-1;
}
&.tag-sup2 {
background: darken($supporter-2, 7%);
background: linear-gradient(to bottom, #ffcd99, #ffaa3b 10%, #c77305);
color: #3b2f1a;
}
&.tag-sup3 {
background: darken($supporter-3, 7%);
background: linear-gradient(to bottom, #fffda4, #ffde3b 10%, #fdbb0b);
color: #49391c;
}
&.tag-friend {
background: $friends-color;
}
}
.pony-box-rect {
background: white;
position: absolute;
top: 15px;
left: 50px;
width: 250px;
height: 60px;
border-top-right-radius: 15px;
border-bottom-right-radius: 15px;
padding-left: 58px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
}
.pony-box-name {
font-size: 20px;
padding-top: 3px;
padding-right: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pony-box-buttons {
padding-top: 2px;
padding-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pony-box-buttons-box {
position: absolute;
top: 65px;
left: 48px;
white-space: nowrap;
}
.pony-box-avatar {
position: absolute;
}
.remove-confirmation {
background: white;
position: absolute;
left: 0;
top: 0;
right: 0;
height: 100%;
}
@@ -0,0 +1,113 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { PlayerAction, Pony, EntityPlayerState, Entity } from '../../../common/interfaces';
import { getPaletteInfo } from '../../../common/pony';
import { Model } from '../../services/model';
import { PonyTownGame } from '../../../client/game';
import {
partyLeaderIcon, faUserPlus, faUserTimes, faCheck, faMicrophoneSlash, faEyeSlash, faStar, faUserMinus,
faUserCog, faComment
} from '../../../client/icons';
import { DAY } from '../../../common/constants';
import { isPonyInParty, isPartyLeader } from '../../../client/partyUtils';
import { getTag } from '../../../common/tags';
import { isIgnored, isHidden, isFriend } from '../../../common/entityUtils';
import { setFlag } from '../../../common/utils';
@Component({
selector: 'pony-box',
templateUrl: 'pony-box.pug',
styleUrls: ['pony-box.scss'],
})
export class PonyBox {
readonly leaderIcon = partyLeaderIcon;
readonly inviteIcon = faUserPlus;
readonly removeIcon = faUserTimes;
readonly cogIcon = faUserCog;
readonly checkIcon = faCheck;
readonly ignoreIcon = faMicrophoneSlash;
readonly hideIcon = faEyeSlash;
readonly starIcon = faStar;
readonly addFriendIcon = faUserPlus;
readonly removeFriendIcon = faUserMinus;
readonly messageIcon = faComment;
isIgnored = isIgnored;
isFriend = isFriend;
removingFriend = false;
@Input() pony?: Pony;
@Output() sendMessage = new EventEmitter<Entity>();
constructor(private model: Model, private game: PonyTownGame) {
}
get ignoredOrHidden() {
return this.pony && (isIgnored(this.pony) || isHidden(this.pony));
}
get isMod() {
return this.model.isMod;
}
get canInviteToParty() {
return this.pony && (!this.game.party || (isPartyLeader(this.game) && !isPonyInParty(this.game.party, this.pony, true)));
}
get canRemoveFromParty() {
return this.pony && isPartyLeader(this.game) && isPonyInParty(this.game.party, this.pony, true);
}
get canPromoteToLeader() {
return this.pony && isPartyLeader(this.game) && isPonyInParty(this.game.party, this.pony, false);
}
get special() {
const tag = getTag(this.pony && this.pony.tag);
return tag && tag.name;
}
get specialClass() {
const tag = getTag(this.pony && this.pony.tag);
return tag && tag.tagClass;
}
get paletteInfo() {
return this.pony && getPaletteInfo(this.pony);
}
inviteToParty() {
this.playerAction(PlayerAction.InviteToParty);
}
removeFromParty() {
this.playerAction(PlayerAction.RemoveFromParty);
}
promoteToLeader() {
this.playerAction(PlayerAction.PromotePartyLeader);
}
toggleIgnore() {
if (this.pony) {
const ignored = isIgnored(this.pony);
this.playerAction(ignored ? PlayerAction.Unignore : PlayerAction.Ignore);
this.pony.playerState = setFlag(this.pony.playerState, EntityPlayerState.Ignored, !ignored);
}
}
hidePlayer(days: number) {
this.playerAction(PlayerAction.HidePlayer, days * DAY);
}
addFriend() {
this.playerAction(PlayerAction.AddFriend);
}
removeFriend() {
this.playerAction(PlayerAction.RemoveFriend);
}
private playerAction(type: PlayerAction, param: any = undefined) {
const ponyId = this.pony && this.pony.id;
if (ponyId) {
this.game.send(server => server.playerAction(ponyId, type, param));
}
}
sendMessageTo() {
if (this.pony) {
this.sendMessage.emit(this.pony);
}
}
// supporter servers
get canInviteToSupporterServers() {
return false; // DEVELOPMENT; // TODO: check if ignored or hidden
}
get isInvitedToSupporterServers() {
return false;
}
inviteToSupporterServers() {
this.playerAction(PlayerAction.InviteToSupporterServers);
}
}
@@ -0,0 +1,3 @@
.portrait-box([ngClass]="size")
canvas(#canvas)
.portrait-box-cover(*ngIf="!noBorder")

Some files were not shown because too many files have changed in this diff Show More