diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..7b654c1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,20 @@ +name: Validate + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run verify diff --git a/README.md b/README.md index 3159d8a..f05e722 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ A browser client for the [Encounter+](https://encounter.plus) virtual tabletop app. Encounter+ serves this client to players' browsers, TVs and table displays. The client shows the current map, tokens, lighting and fog of war, the initiative order and the chat. Everything updates live over a WebSocket. Depending on how the host has set up the screen, players can also move their tokens, ping the map and send messages. +## Player tools + +- **My character** shows the selected friendly token's sheet and enabled status effects. HP controls appear when the host supplies an HP range, and players can enter an initiative result before combat starts. +- **Ruler** provides precise distances and grid movement. Saved rulers stay in the current browser session and are cleared when the map changes. +- **Area preview** draws local sphere, cylinder, cone, cube and line templates. Overlapping squares are highlighted on square grids; the preview does not create or modify host area effects. +- Character sheets and rule references open in separate draggable windows. Roll and reference links are handled when the hosted sheet is accessible from the client's origin. +- Appearance can follow the device or use light/dark mode. Initiative can dock at the right or bottom, and the selected character receives a turn notification. + +HP and initiative updates use the host's existing `updateCombatant` WebSocket event; rolls use `createMessage`. Host measurements and area effects continue to arrive through their existing model/event paths. Local map tools do not call area-effect or measurement write endpoints, leaving their shared-state integration for a later change. + ## Environment | | Version | @@ -50,7 +60,7 @@ http://localhost:4200/?remoteHost=192.168.1.10:8080 | `device` | e.g. `gameboard` | Marks a dedicated display device. `gameboard` also applies a 1.5× viewport scale. | | `interactions` | `all` | Enables every interaction, whatever the screen settings say. | -User preferences (max FPS, video playback, name, selected token, open panel) are kept in `localStorage`. +User preferences (max FPS, video playback, name, selected token, open panels, appearance and initiative dock position) are kept in `localStorage`. ## Architecture @@ -122,8 +132,9 @@ To rebuild an existing release, run the Release workflow manually with its tag. ## Tests ``` +npm run verify # all unit tests followed by the production build npm test # Karma in watch mode -npm run test:ci # single run +npm run test:ci # unit tests only, single run ``` Specs sit next to the code they cover (`*.spec.ts`). `core/map/testing/` and `shared/models/testing/` hold stubs and fixtures. diff --git a/angular.json b/angular.json index 79a044e..b4515ce 100644 --- a/angular.json +++ b/angular.json @@ -115,18 +115,6 @@ ], "scripts": [] } - }, - "e2e": { - "builder": "@angular-devkit/build-angular:protractor", - "options": { - "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "web-client:serve" - }, - "configurations": { - "production": { - "devServerTarget": "web-client:serve:production" - } - } } } } @@ -160,4 +148,4 @@ "typeSeparator": "." } } -} \ No newline at end of file +} diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js deleted file mode 100644 index 7c798cf..0000000 --- a/e2e/protractor.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-check -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -const { SpecReporter } = require('jasmine-spec-reporter'); - -/** - * @type { import("protractor").Config } - */ -exports.config = { - allScriptsTimeout: 11000, - specs: [ - './src/**/*.e2e-spec.ts' - ], - capabilities: { - browserName: 'chrome' - }, - directConnect: true, - baseUrl: 'http://localhost:4200/', - framework: 'jasmine', - jasmineNodeOpts: { - showColors: true, - defaultTimeoutInterval: 30000, - print: function() {} - }, - onPrepare() { - require('ts-node').register({ - project: require('path').join(__dirname, './tsconfig.json') - }); - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); - } -}; \ No newline at end of file diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts deleted file mode 100644 index 147c5a7..0000000 --- a/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppPage } from './app.po'; -import { browser, logging } from 'protractor'; - -describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', () => { - page.navigateTo(); - expect(page.getTitleText()).toEqual('external-screen app is running!'); - }); - - afterEach(async () => { - // Assert that there are no errors emitted from the browser - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); - }); -}); diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts deleted file mode 100644 index b68475e..0000000 --- a/e2e/src/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, by, element } from 'protractor'; - -export class AppPage { - navigateTo(): Promise { - return browser.get(browser.baseUrl) as Promise; - } - - getTitleText(): Promise { - return element(by.css('app-root .content span')).getText() as Promise; - } -} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json deleted file mode 100644 index c92199c..0000000 --- a/e2e/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/e2e", - "module": "commonjs", - "target": "es2018", - "types": [ - "jasmine", - "jasminewd2", - "node" - ] - } -} diff --git a/karma.conf.js b/karma.conf.js index 7159884..0d94975 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -8,12 +8,8 @@ module.exports = function (config) { plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), require('karma-coverage') ], - client: { - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, coverageReporter: { dir: require('path').join(__dirname, './coverage/web-client'), subdir: '.', @@ -23,10 +19,14 @@ module.exports = function (config) { { type: 'text-summary' } ] }, - reporters: ['progress', 'kjhtml'], + reporters: ['dots'], + browserConsoleLogOptions: { + level: 'error', + terminal: true + }, port: 9876, - colors: true, - logLevel: config.LOG_INFO, + colors: false, + logLevel: config.LOG_WARN, autoWatch: true, browsers: ['ChromeHeadless'], singleRun: false, diff --git a/package-lock.json b/package-lock.json index 2f95d0b..d0a8e42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,6 @@ "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", "typescript": "^6.0.3" } }, @@ -10993,18 +10992,6 @@ "karma": "^6.0.0" } }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.2.0.tgz", - "integrity": "sha512-J0laEC43Oy2RdR5V5R3bqmdo7yRIYySq6XHKbA+e5iSAgLjhR1oICLGeSREPlJXpeyNcdJf3J17YcdhD0mRssQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0 || ^6.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, "node_modules/karma-jasmine/node_modules/jasmine-core": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", diff --git a/package.json b/package.json index 1008113..daa00d4 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "build:prod": "ng build --configuration production", + "build:prod": "ng build --configuration production --progress=false", "package": "npm run build:prod && node scripts/package.mjs", "test": "ng test", - "test:ci": "ng test --watch=false", + "test:ci": "ng test --watch=false --progress=false --reporters=dots", + "verify": "node scripts/verify.mjs", "lint": "ng lint", - "e2e": "ng e2e", "version": "node scripts/sync-version.mjs && git add src/manifest.json" }, "private": true, @@ -53,7 +53,6 @@ "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", "typescript": "^6.0.3" } } diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..35b60e2 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const ng = path.join(root, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'); + +function run(label, args, summarize = () => label) { + const result = spawnSync(process.execPath, [ng, ...args], { + cwd: root, + env: process.env, + encoding: 'utf8' + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + + if (result.status !== 0) { + process.stderr.write(`Verification failed: ${label}\n${output}`); + process.exit(result.status ?? 1); + } + + console.log(`✓ ${summarize(output)}`); +} + +run( + 'unit tests', + ['test', '--watch=false', '--progress=false', '--reporters=dots'], + output => { + const match = output.match(/Executed (\d+) of \1 SUCCESS/); + return match ? `${match[1]} unit tests` : 'unit tests'; + } +); +run('production build', ['build', '--configuration', 'production', '--progress=false']); diff --git a/src/app/app.component.events.spec.ts b/src/app/app.component.events.spec.ts index d8dd411..5c77001 100644 --- a/src/app/app.component.events.spec.ts +++ b/src/app/app.component.events.spec.ts @@ -9,16 +9,19 @@ import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { EMPTY, Subject } from 'rxjs'; +import { EMPTY, of, Subject } from 'rxjs'; import { AppComponent } from './app.component'; import { AppState, RunMode } from './shared/models/app-state'; import { DataService } from './shared/services/data.service'; import { WSEvent, WSEventName } from './shared/models/wsevent'; import { MapLayer } from './shared/models/map'; import { ControlState } from './core/map/views/token-view'; +import { Role } from './shared/models/token'; +import { playerEffects } from './shared/player-tools'; import { mapComponentStub, modelViewStub, tileViewStub, tokenViewStub } from './core/map/testing/map-component-stub'; import { minimalAreaEffect, + minimalApiData, minimalCombatant, minimalStatusEffect, minimalDrawing, @@ -75,6 +78,29 @@ describe('AppComponent websocket events', () => { app.handleEvent({ name, data }); } + it('rerenders an already-open player panel when the initial API data arrives', async () => { + const initialRevision = app.playerStateRevision(); + app.state.userTokenId = 'token-1'; + + const service = TestBed.inject(DataService) as any; + service.getData.and.returnValue(of(minimalApiData({ + map: minimalMap({ + tokens: [minimalToken({ id: 'token-1', name: 'Mira', role: Role.friendly })], + }), + game: { + turn: 0, + round: 0, + started: false, + combatants: [minimalCombatant({ id: 'hero-1', tokenId: 'token-1', name: 'Mira' })], + }, + }))); + + app.getData(); + + expect(app.state.map?.tokens[0].name).toBe('Mira'); + expect(app.playerStateRevision()).toBe(initialRevision + 1); + }); + describe('systemPaused', () => { it('mirrors the flag into the state and the signal', () => { @@ -122,6 +148,46 @@ describe('AppComponent websocket events', () => { it('survives a game with no combatants at all', () => { expect(() => send(WSEventName.gameUpdated, {})).not.toThrow(); }); + + it('announces the assigned character when their turn begins', () => { + app.state.userTokenId = 'token-1'; + app.state.map = minimalMap({ + tokens: [minimalToken({ id: 'token-1', role: Role.friendly })], + }); + app.state.game.combatants = [minimalCombatant({ + id: 'hero-1', + tokenId: 'token-1', + name: 'Mira', + label: 'G', + })]; + + send(WSEventName.gameUpdated, { + started: true, + turn: 1, + round: 2, + combatantId: 'hero-1', + }); + + expect(app.turnNotice()).toEqual({ + combatantId: 'hero-1', + name: 'Mira', + round: 2, + }); + }); + + it('clears the turn notice when play moves to another combatant', () => { + app.state.userTokenId = 'token-1'; + app.state.map = minimalMap({ + tokens: [minimalToken({ id: 'token-1', role: Role.friendly })], + }); + app.state.game.combatants = [minimalCombatant({ id: 'hero-1', tokenId: 'token-1' })]; + + send(WSEventName.gameUpdated, { started: true, turn: 1, round: 1, combatantId: 'hero-1' }); + expect(app.turnNotice()).toBeDefined(); + + send(WSEventName.gameUpdated, { turn: 2, combatantId: 'enemy-1' }); + expect(app.turnNotice()).toBeUndefined(); + }); }); describe('combatantUpdated', () => { @@ -148,6 +214,60 @@ describe('AppComponent websocket events', () => { expect(() => send(WSEventName.combatantUpdated, { id: "z", bloodied: true })).not.toThrow(); expect(app.state.game.combatants).toEqual([]); }); + + it('refreshes an open player panel when effects change or the final effect is explicitly removed', () => { + const initialRevision = app.playerStateRevision(); + app.state.game.combatants = [minimalCombatant({ + id: 'hero-1', + effects: [{ id: 'frightened', name: 'Frightened' }], + })]; + + send(WSEventName.combatantUpdated, { + id: 'hero-1', + effects: [], + }); + + expect(app.state.game.combatants[0].effects).toEqual([]); + expect(app.playerStateRevision()).toBe(initialRevision + 1); + }); + + it("clears the final effect from both the game and token combatant copies", () => { + const initialRevision = app.playerStateRevision(); + app.state.userTokenId = 'token-1'; + app.state.map = minimalMap({ + tokens: [minimalToken({ + id: 'token-1', + role: Role.friendly, + combatant: minimalCombatant({ + id: 'hero-1', + tokenId: 'token-1', + effects: [{ id: 'frightened', name: 'Frightened' }], + }), + })], + }); + app.state.game.combatants = [minimalCombatant({ + id: 'hero-1', + tokenId: 'token-1', + effects: [{ id: 'frightened', name: 'Frightened' }], + })]; + + send(WSEventName.combatantUpdated, { id: 'hero-1', effects: [] }); + + expect(playerEffects(app.state.game.combatants[0])).toEqual([]); + expect(playerEffects(app.state.map.tokens[0].combatant)).toEqual([]); + expect(app.playerStateRevision()).toBe(initialRevision + 1); + }); + + it('preserves effects when a genuinely partial update omits that field', () => { + app.state.game.combatants = [minimalCombatant({ + id: 'hero-1', + effects: [{ id: 'frightened', name: 'Frightened' }], + })]; + + send(WSEventName.combatantUpdated, { id: 'hero-1', bloodied: true }); + + expect(playerEffects(app.state.game.combatants[0]).map(effect => effect.name)).toEqual(['Frightened']); + }); }); describe('mapUpdated', () => { @@ -226,6 +346,14 @@ describe('AppComponent websocket events', () => { expect(container.lightsLayer.draw).toHaveBeenCalled(); }); + it('refreshes an open player panel when its assigned token changes', async () => { + const initialRevision = app.playerStateRevision(); + + send(WSEventName.tokenUpdated, minimalToken({ id: 'token-1' })); + + expect(app.playerStateRevision()).toBe(initialRevision + 1); + }); + it('does nothing when there is no map', () => { app.state.map = undefined; expect(() => send(WSEventName.tokenUpdated, minimalToken())).not.toThrow(); diff --git a/src/app/app.component.html b/src/app/app.component.html index 0a27ee9..02db93a 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,15 +1,71 @@ -
+
- + + + @if (showPlayerPanel) { + + } + + @if (characterWindow) { +
+ +
+ } + + @if (referenceWindow) { +
+ +
+ } + + @if (turnNotice(); as notice) { +
+ + + Your turn + {{ notice.name }}@if (notice.round) { · Round {{ notice.round }}} + + +
+ } @if (zoomControls) { } + @if (this.game().started && this.activeCombatants().length > 0) { + + } + diff --git a/src/app/core/message-list/message-list.component.scss b/src/app/core/message-list/message-list.component.scss index 11f0527..861801b 100644 --- a/src/app/core/message-list/message-list.component.scss +++ b/src/app/core/message-list/message-list.component.scss @@ -5,7 +5,8 @@ // top: 0; // right: 0; // bottom: 0; - background-color: rgba($color: #1d2124, $alpha: 0.92); + color: var(--ui-text); + background-color: var(--ui-surface); flex: 1; @media (max-width: 420px) { @@ -24,8 +25,8 @@ .message-input { // display: flex; - border-top: 1px solid #666; - background-color: #1d2124; + border-top: 1px solid var(--ui-border); + background-color: var(--ui-surface-solid); position: absolute; flex: 1; width: 300px; diff --git a/src/app/core/message-list/message-list.component.ts b/src/app/core/message-list/message-list.component.ts index 6c96a4d..d2e9460 100644 --- a/src/app/core/message-list/message-list.component.ts +++ b/src/app/core/message-list/message-list.component.ts @@ -127,8 +127,8 @@ export class MessageListComponent implements OnInit { content: "Dice roll command:
/r[oll] <dice notation> [[title[:check|save|attack|damage]]]" + "Examples:
" + "" + - "/r 2d20kh — advantage\n" + - "/r 2d20kl — disadvantage\n" + + "/r 2d20kh — keep highest\n" + + "/r 2d20kl — keep lowest\n" + "/r 4d6dl — drop lowest\n" + "
" + "/r 1d20+3 [initiative]\n" + diff --git a/src/app/core/message/message.component.html b/src/app/core/message/message.component.html index ffb9463..f7697b7 100644 --- a/src/app/core/message/message.component.html +++ b/src/app/core/message/message.component.html @@ -1,7 +1,7 @@
◉ @if (message.creature) { - {{message.creature}} | - } {{message.source || "Dungeon Master"}} + {{message.creature}} | + } {{message.source || "Host"}}
@if (isDiceRoll) { diff --git a/src/app/core/message/message.component.scss b/src/app/core/message/message.component.scss index a64ed5e..d3bf002 100644 --- a/src/app/core/message/message.component.scss +++ b/src/app/core/message/message.component.scss @@ -1,10 +1,14 @@ :host { - color: darkgray; + color: var(--ui-text-muted); padding: 8px; display: block; font-size: 0.9rem; - border-bottom: 1px solid #333; + border-bottom: 1px solid var(--ui-border); + + .message-creature { + color: var(--ui-text-muted); + } .frame { overflow-x: hidden; @@ -12,10 +16,10 @@ .message-source { font-size: 0.8rem; - color: white; + color: var(--ui-text); time { - color: darkgray; + color: var(--ui-text-muted); font-size: 0.6rem; } } @@ -39,7 +43,7 @@ } .roll-formula { - color: white; + color: var(--ui-text); font-size: 1.1rem; } @@ -53,7 +57,7 @@ display: block; float: right; font-size: 2.2rem; - color: white; + color: var(--ui-text); min-width: 60px; max-width: 120px; text-align: center; diff --git a/src/app/core/message/message.component.spec.ts b/src/app/core/message/message.component.spec.ts index 2aec044..d630ba3 100644 --- a/src/app/core/message/message.component.spec.ts +++ b/src/app/core/message/message.component.spec.ts @@ -56,9 +56,9 @@ describe('MessageComponent', () => { render(minimalMessage()); }); - it('falls back to the Dungeon Master and renders no body', () => { + it('falls back to the neutral Host label and renders no body', () => { const source = fixture.nativeElement.querySelector('.message-source').textContent; - expect(source).toContain("Dungeon Master"); + expect(source).toContain("Host"); expect(fixture.nativeElement.querySelector('.message-chat').textContent).toBe(""); }); diff --git a/src/app/core/player-panel/player-panel.component.html b/src/app/core/player-panel/player-panel.component.html new file mode 100644 index 0000000..43b9d69 --- /dev/null +++ b/src/app/core/player-panel/player-panel.component.html @@ -0,0 +1,95 @@ +
+
+
+ My character +

{{combatant?.name || token?.name || 'No character selected'}}

+
+ +
+ + @if (!token) { +
+ +

Choose your friendly character token in Menu → Settings.

+
+ } @else { + @if (!combatant) { +
+ + The GM needs to add this character to the encounter before HP and initiative can be updated. +
+ } @else { + @if (hp) { +
+
Hit Points
+
+ + / {{hp.maximum}} + + +
+
+ } + +
+
Initiative
+ @if (canSetInitiative) { +
+ + +
+ Enter the total you rolled. The host starts and sorts combat. + } @else { +
{{currentInitiative ?? '—'}}
+ Initiative is locked here while combat is running. + } +
+ + @if (activeEffects.length) { +
+
Active Effects
+
+ @for (effect of activeEffects; track effect.id) { + + } +
+
+ } + } + + + } +
diff --git a/src/app/core/player-panel/player-panel.component.scss b/src/app/core/player-panel/player-panel.component.scss new file mode 100644 index 0000000..cf1f9cb --- /dev/null +++ b/src/app/core/player-panel/player-panel.component.scss @@ -0,0 +1,256 @@ +:host { + position: absolute; + // Keep the panel above the map but below Bootstrap dropdowns and modals. + z-index: 900; + top: 58px; + left: 8px; + width: min(320px, calc(100vw - 16px)); + max-height: calc(100vh - 74px); + overflow: auto; +} + +.player-card { + color: var(--ui-text); + background: var(--ui-surface); + border: 1px solid var(--ui-border); + border-radius: 0.25rem; + box-shadow: 0 0.4rem 1rem var(--ui-shadow); + overflow: hidden; +} + +.player-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 0.8rem; + background: var(--ui-surface-solid); + border-bottom: 1px solid var(--ui-border); + + h2 { + margin: 0; + font-size: 1.1rem; + line-height: 1.15; + } +} + +.eyebrow, +.section-title, +label span { + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} + +.eyebrow { + display: none; +} + +.tool-section { + padding: 0.75rem 0.8rem; + border-bottom: 1px solid var(--ui-border); +} + +.availability-note { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.75rem 0.8rem; + color: var(--ui-text-muted); + border-bottom: 1px solid var(--ui-border); + + i { + margin-top: 0.18rem; + color: var(--ui-accent); + } +} + +.section-title { + margin-bottom: 0.55rem; + color: var(--ui-text); + font-size: 0.74rem; +} + +.hp-row, +.initiative-row { + display: flex; + align-items: end; + gap: 0.45rem; + + input { + width: 72px; + min-height: 34px; + color: var(--ui-text); + background: var(--ui-control); + border: 1px solid var(--ui-border); + border-radius: 6px; + padding: 0.3rem 0.45rem; + } +} + +label { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin: 0; + + span { + color: var(--ui-text-muted); + font-size: 0.64rem; + } +} + +.maximum { + align-self: center; + color: var(--ui-text-muted); + font-weight: 700; + margin-top: 0.8rem; +} + +.initiative-result { + font-size: 1.75rem; + font-weight: 700; + color: var(--ui-text); +} + +.effect-list { + display: grid; + gap: 0.4rem; +} + +.effect-chip { + display: flex; + width: 100%; + min-height: 2.65rem; + padding: 0.38rem 0.5rem; + align-items: center; + gap: 0.5rem; + color: var(--ui-text); + background: var(--ui-control); + border: 1px solid var(--ui-border); + border-left-width: 0.28rem; + border-radius: 6px; + text-align: left; + + &:disabled { + cursor: default; + opacity: 1; + } + + &.has-details:hover { + background: var(--ui-control-hover); + border-color: var(--ui-border-strong); + } + + > img, + > i:first-child { + width: 1.35rem; + height: 1.35rem; + flex: 0 0 1.35rem; + } + + > img { + object-fit: contain; + } + + > i:first-child { + display: grid; + place-items: center; + color: var(--ui-text-muted); + } + + > span { + min-width: 0; + flex: 1; + } + + strong, + small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + margin: 0.08rem 0 0; + } +} + +.effect-chevron { + color: var(--ui-text-muted); + font-size: 0.72rem; +} + +.btn-primary { + color: var(--ui-accent-text); + background: var(--ui-accent); + border-color: var(--ui-accent); + + &:hover:not(:disabled) { + background: var(--ui-accent-hover); + border-color: var(--ui-accent-hover); + } +} + +small { + display: block; + margin-top: 0.4rem; + color: var(--ui-text-muted); + line-height: 1.3; +} + +.sheet-button { + width: calc(100% - 1.6rem); + margin: 0.75rem 0.8rem 0.8rem; + padding: 0.6rem 0.7rem; + display: flex; + align-items: center; + gap: 0.7rem; + text-align: left; + color: var(--ui-text); + background: var(--ui-control); + border: 1px solid var(--ui-border); + border-radius: 8px; + + span { + flex: 1; + } + + small { + margin: 0.1rem 0 0; + } + + &:hover:not(:disabled) { + background: var(--ui-control-hover); + } + + &:disabled { + opacity: 0.5; + } +} + +.empty-state { + padding: 1.4rem 1rem; + text-align: center; + color: var(--ui-text-muted); + + i { + font-size: 2rem; + color: var(--ui-accent); + } + + p { + margin: 0.65rem 0 0; + } +} + +@media (max-width: 420px) { + :host { + top: 54px; + } + + .hp-row { + flex-wrap: wrap; + } +} diff --git a/src/app/core/player-panel/player-panel.component.spec.ts b/src/app/core/player-panel/player-panel.component.spec.ts new file mode 100644 index 0000000..0cd7bda --- /dev/null +++ b/src/app/core/player-panel/player-panel.component.spec.ts @@ -0,0 +1,180 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { AppState } from 'src/app/shared/models/app-state'; +import { Role } from 'src/app/shared/models/token'; +import { minimalCombatant, minimalToken } from 'src/app/shared/models/testing/fixtures'; +import { WSEventName } from 'src/app/shared/models/wsevent'; +import { DataService } from 'src/app/shared/services/data.service'; +import { PlayerPanelComponent } from './player-panel.component'; + +describe('PlayerPanelComponent', () => { + function buildState(): AppState { + const state = new AppState(); + state.userTokenId = 'token-1'; + state.map = { + tokens: [ + minimalToken({ id: 'token-1', name: 'One', role: Role.friendly, reference: '/character/one' }), + minimalToken({ id: 'token-2', name: 'Two', role: Role.friendly, reference: '/character/two' }), + ], + } as any; + state.game.combatants = [ + minimalCombatant({ + id: 'combatant-1', + tokenId: 'token-1', + data: { hp: { current: 10, maximum: 10, temporary: 0 } }, + initiative: [{ id: 'initiative-1', value: null }], + }), + minimalCombatant({ + id: 'combatant-2', + tokenId: 'token-2', + data: { hp: { current: 16, maximum: 20, temporary: 1 } }, + initiative: [{ id: 'initiative-2', value: null }], + }), + ]; + return state; + } + + function createComponent(state = buildState()) { + const dataService = { send: jasmine.createSpy('send') }; + const component = new PlayerPanelComponent(dataService as any); + component.state = state; + component.ngOnInit(); + return { component, dataService, state }; + } + + it('reloads drafts when the assigned token changes', () => { + const { component, state } = createComponent(); + component.currentHP = 3; + component.hpDirty = true; + + state.userTokenId = 'token-2'; + component.ngDoCheck(); + + expect(component.currentHP).toBe(16); + expect(component.temporaryHP).toBe(1); + expect(component.sheetReference).toBe('/character/two'); + }); + + it('preserves a local draft while refreshing untouched server values', () => { + const { component, state } = createComponent(); + component.currentHP = 4; + component.hpDirty = true; + state.game.combatants[0].data.hp.current = 7; + + component.ngDoCheck(); + + expect(component.currentHP).toBe(4); + }); + + it('sends a partial combatant HP update', () => { + const { component, dataService } = createComponent(); + component.currentHP = 6; + component.temporaryHP = 2; + component.hpDirty = true; + + component.saveHitPoints(); + + expect(dataService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + name: WSEventName.updateCombatant, + data: jasmine.objectContaining({ + id: 'combatant-1', + data: jasmine.objectContaining({ hp: jasmine.objectContaining({ current: 6, temporary: 2 }) }), + }), + })); + }); + + it('does not allow initiative updates after combat starts', () => { + const { component, dataService, state } = createComponent(); + state.game.started = true; + component.initiativeValue = 18; + + component.saveInitiative(); + + expect(dataService.send).not.toHaveBeenCalled(); + }); + + it('allows the player to set initiative before an initiative entry exists', () => { + const { component, dataService, state } = createComponent(); + state.game.combatants[0].initiative = []; + component.initiativeValue = 15; + + expect(component.canSetInitiative).toBeTrue(); + component.saveInitiative(); + + const event = dataService.send.calls.mostRecent().args[0]; + expect(event.name).toBe(WSEventName.updateCombatant); + expect(event.data.initiative.length).toBe(1); + expect(event.data.initiative[0].value).toBe(15); + expect(event.data.initiative[0].id).toBeTruthy(); + }); +}); + +describe('PlayerPanelComponent live rendering', () => { + let fixture: ComponentFixture; + let component: PlayerPanelComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PlayerPanelComponent], + imports: [FormsModule], + providers: [{ + provide: DataService, + useValue: { baseURL: 'http://localhost', send: jasmine.createSpy('send') }, + }], + }).compileComponents(); + + fixture = TestBed.createComponent(PlayerPanelComponent); + component = fixture.componentInstance; + }); + + it('replaces the empty state and clears the final effect whenever its parent refreshes', () => { + const state = new AppState(); + state.userTokenId = 'token-1'; + state.map = { tokens: [] } as any; + component.state = state; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Choose your friendly character token'); + + state.map = { + tokens: [minimalToken({ id: 'token-1', name: 'Mira', role: Role.friendly })], + } as any; + state.game.combatants = [minimalCombatant({ + id: 'hero-1', + tokenId: 'token-1', + name: 'Mira', + effects: [{ id: 'frightened', name: 'Frightened' }], + })]; + fixture.componentRef.setInput('stateRevision', 1); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.player-header')?.textContent).toContain('Mira'); + expect(fixture.nativeElement.querySelector('.effect-section')?.textContent).toContain('Frightened'); + + state.game.combatants[0].effects = []; + fixture.componentRef.setInput('stateRevision', 2); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.effect-section')).toBeNull(); + }); + + it("uses the token's live combatant effects instead of a stale game copy", () => { + const state = new AppState(); + state.userTokenId = 'token-1'; + state.map = { + tokens: [minimalToken({ + id: 'token-1', + name: 'Mira', + role: Role.friendly, + combatant: minimalCombatant({ id: 'hero-1', tokenId: 'token-1', effects: [] }), + })], + } as any; + state.game.combatants = [minimalCombatant({ + id: 'hero-1', + tokenId: 'token-1', + effects: [{ id: 'frightened', name: 'Frightened' }], + })]; + component.state = state; + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.effect-section')).toBeNull(); + }); +}); diff --git a/src/app/core/player-panel/player-panel.component.ts b/src/app/core/player-panel/player-panel.component.ts new file mode 100644 index 0000000..b7bb33b --- /dev/null +++ b/src/app/core/player-panel/player-panel.component.ts @@ -0,0 +1,160 @@ +import { Component, DoCheck, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AppState } from 'src/app/shared/models/app-state'; +import { Combatant } from 'src/app/shared/models/combatant'; +import { Token } from 'src/app/shared/models/token'; +import { WSEventName } from 'src/app/shared/models/wsevent'; +import { + assignedPlayerCombatant, + assignedPlayerReference, + assignedPlayerToken, + combatantWithHitPoints, + combatantWithInitiative, + hitPoints, + PlayerEffect, + playerEffects, +} from 'src/app/shared/player-tools'; +import { DataService } from 'src/app/shared/services/data.service'; + +@Component({ + selector: 'app-player-panel', + templateUrl: './player-panel.component.html', + styleUrls: ['./player-panel.component.scss'], + standalone: false, +}) +export class PlayerPanelComponent implements OnInit, DoCheck { + @Input() state!: AppState; + @Input() stateRevision = 0; + @Output() closePanel = new EventEmitter(); + @Output() showSheet = new EventEmitter(); + @Output() showEffect = new EventEmitter(); + + currentHP: number | null = 0; + temporaryHP: number | null = 0; + initiativeValue: number | null = 0; + + hpDirty = false; + initiativeDirty = false; + + private trackedTokenId?: string; + private trackedCurrentHP?: number; + private trackedTemporaryHP?: number; + private trackedInitiative?: number; + + constructor(private dataService: DataService) {} + + get token(): Token | undefined { + return assignedPlayerToken(this.state); + } + + get combatant(): Combatant | undefined { + return assignedPlayerCombatant(this.state); + } + + get hp() { + return hitPoints(this.combatant); + } + + get sheetReference(): string | undefined { + return assignedPlayerReference(this.state); + } + + get activeEffects(): PlayerEffect[] { + // Encounter+ keeps the live status-effect collection on the token's combatant copy. + return playerEffects(this.token?.combatant ?? this.combatant); + } + + get canSetInitiative(): boolean { + return !this.state.game.started && Boolean(this.combatant); + } + + get hitPointInputsValid(): boolean { + return this.currentHP !== null && this.temporaryHP !== null + && Number.isFinite(Number(this.currentHP)) && Number.isFinite(Number(this.temporaryHP)); + } + + get initiativeInputValid(): boolean { + return this.initiativeValue !== null && Number.isFinite(Number(this.initiativeValue)); + } + + get currentInitiative(): number | undefined { + const value = this.combatant?.initiative?.[0]?.value; + return value !== null && value !== undefined && Number.isFinite(Number(value)) ? Number(value) : undefined; + } + + ngOnInit(): void { + this.synchronizeDrafts(true); + } + + ngDoCheck(): void { + this.synchronizeDrafts(false); + } + + private synchronizeDrafts(force: boolean): void { + const tokenChanged = this.trackedTokenId !== this.token?.id; + const hp = this.hp; + const initiative = this.currentInitiative; + + if (force || tokenChanged || (!this.hpDirty && ( + this.trackedCurrentHP !== hp?.current || this.trackedTemporaryHP !== hp?.temporary + ))) { + this.currentHP = hp?.current ?? 0; + this.temporaryHP = hp?.temporary ?? 0; + this.hpDirty = false; + } + + if (force || tokenChanged || (!this.initiativeDirty && this.trackedInitiative !== initiative)) { + this.initiativeValue = initiative ?? 0; + this.initiativeDirty = false; + } + + this.trackedTokenId = this.token?.id; + this.trackedCurrentHP = hp?.current; + this.trackedTemporaryHP = hp?.temporary; + this.trackedInitiative = initiative; + } + + saveHitPoints(): void { + const combatant = this.combatant; + if (!combatant || !this.hp || !this.hitPointInputsValid) return; + + const patch = combatantWithHitPoints(combatant, Number(this.currentHP), Number(this.temporaryHP)); + Object.assign(combatant, patch); + this.hpDirty = false; + this.synchronizeDrafts(true); + this.dataService.send({ name: WSEventName.updateCombatant, data: patch }); + } + + saveInitiative(): void { + const combatant = this.combatant; + if (!combatant || !this.canSetInitiative || !this.initiativeInputValid) return; + + const patch = combatantWithInitiative(combatant, Number(this.initiativeValue)); + Object.assign(combatant, patch); + this.initiativeDirty = false; + this.synchronizeDrafts(true); + this.dataService.send({ name: WSEventName.updateCombatant, data: patch }); + } + + openSheet(): void { + if (this.sheetReference) this.showSheet.emit(this.sheetReference); + } + + effectHasDetails(effect: PlayerEffect): boolean { + return Boolean(effect.reference || effect.description); + } + + effectLabel(effect: PlayerEffect): string { + return effect.value ? `${effect.name} ${effect.value}` : effect.name; + } + + effectIconUrl(effect: PlayerEffect): string | undefined { + const icon = effect.icon; + if (!icon || !/\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i.test(icon)) return undefined; + if (/^(?:data:|https?:)/i.test(icon)) return icon; + return `${this.dataService.baseURL}/${icon.replace(/^\//, '')}`; + } + + openEffect(effect: PlayerEffect): void { + if (this.effectHasDetails(effect)) this.showEffect.emit(effect); + } +} diff --git a/src/app/core/settings-modal/settings-modal.component.html b/src/app/core/settings-modal/settings-modal.component.html index 0370960..5a8376a 100644 --- a/src/app/core/settings-modal/settings-modal.component.html +++ b/src/app/core/settings-modal/settings-modal.component.html @@ -18,13 +18,24 @@
- + +
This controls which friendly token you can move and which character tools you see.
+
+ +
+ + +
Automatic follows this device's light or dark appearance.
diff --git a/src/app/core/settings-modal/settings-modal.component.spec.ts b/src/app/core/settings-modal/settings-modal.component.spec.ts index 5f4dc3e..712e818 100644 --- a/src/app/core/settings-modal/settings-modal.component.spec.ts +++ b/src/app/core/settings-modal/settings-modal.component.spec.ts @@ -5,14 +5,19 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ColorPickerDirective } from 'ngx-color-picker'; import { AppState } from 'src/app/shared/models/app-state'; +import { Appearance } from 'src/app/shared/appearance'; +import { DataService } from 'src/app/shared/services/data.service'; import { SettingsModalComponent } from './settings-modal.component'; describe('SettingsModalComponent', () => { let component: SettingsModalComponent; let fixture: ComponentFixture; + let dataService: DataService; beforeEach(async () => { + localStorage.clear(); + document.documentElement.removeAttribute('data-theme'); await TestBed.configureTestingModule({ declarations: [ SettingsModalComponent ], imports: [ FormsModule, ColorPickerDirective ], @@ -26,10 +31,16 @@ describe('SettingsModalComponent', () => { fixture = TestBed.createComponent(SettingsModalComponent); component = fixture.componentInstance; + dataService = TestBed.inject(DataService); component.state = new AppState(); fixture.detectChanges(); }); + afterEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-theme'); + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -37,4 +48,36 @@ describe('SettingsModalComponent', () => { it('should have no tokens without a map', () => { expect(component.tokens).toEqual([]); }); + + it('restores community and upstream preferences together', () => { + localStorage.setItem('appearance', Appearance.dark); + localStorage.setItem('userTokenId', 'token-1'); + localStorage.setItem('playVideoAssets', 'false'); + + component.ngOnInit(); + + expect(component.appearance).toBe(Appearance.dark); + expect(component.tokenId).toBe('token-1'); + expect(component.playVideoAssets).toBeFalse(); + }); + + it('saves appearance and video preferences while clearing an unassigned token', () => { + spyOn(dataService, 'send'); + spyOn(component.modalInstance, 'close'); + localStorage.setItem('userTokenId', 'old-token'); + component.state.userTokenId = 'old-token'; + component.remoteHost = dataService.remoteHost; + component.runMode = component.state.runMode; + component.tokenId = null; + component.appearance = Appearance.light; + component.playVideoAssets = false; + + component.save(); + + expect(localStorage.getItem('userTokenId')).toBeNull(); + expect(component.state.userTokenId).toBeUndefined(); + expect(localStorage.getItem('appearance')).toBe(Appearance.light); + expect(localStorage.getItem('playVideoAssets')).toBe('false'); + expect(document.documentElement.getAttribute('data-theme')).toBe(Appearance.light); + }); }); diff --git a/src/app/core/settings-modal/settings-modal.component.ts b/src/app/core/settings-modal/settings-modal.component.ts index f8083f7..14ca234 100644 --- a/src/app/core/settings-modal/settings-modal.component.ts +++ b/src/app/core/settings-modal/settings-modal.component.ts @@ -5,6 +5,7 @@ import { Utils } from 'src/app/shared/utils'; import { WSEventName } from 'src/app/shared/models/wsevent'; import { Role, Token } from 'src/app/shared/models/token'; import { AppState, RunMode } from 'src/app/shared/models/app-state'; +import { Appearance, saveAppearance, storedAppearance } from 'src/app/shared/appearance'; import { Loader } from '../map/models/loader'; @Component({ @@ -28,7 +29,13 @@ export class SettingsModalComponent implements OnInit { runModeOptions: Array = [RunMode.normal, RunMode.tv] runMode: RunMode = RunMode.normal - tokenId?: string + appearanceOptions = [ + { value: Appearance.automatic, label: "Automatic" }, + { value: Appearance.light, label: "Light" }, + { value: Appearance.dark, label: "Dark" }, + ] + appearance: Appearance = Appearance.automatic + tokenId?: string | null get tokens(): Array { if (this.state.map != null) { @@ -49,7 +56,13 @@ export class SettingsModalComponent implements OnInit { save() { localStorage.setItem("userName", this.name) localStorage.setItem("userColor", this.color) - localStorage.setItem("userTokenId", this.tokenId ?? "") + if (this.tokenId) { + localStorage.setItem("userTokenId", this.tokenId) + this.state.userTokenId = this.tokenId + } else { + localStorage.removeItem("userTokenId") + this.state.userTokenId = undefined + } localStorage.setItem("maxFPS", `${this.maxFPS}`) localStorage.setItem("allowVideo", `${this.allowVideo}`) @@ -60,6 +73,7 @@ export class SettingsModalComponent implements OnInit { localStorage.setItem("playVideoAssets", `${this.playVideoAssets}`) localStorage.setItem("softEdges", `${this.softEdges}`) localStorage.setItem("runMode", `${this.runMode}`) + saveAppearance(this.appearance) // update server this.dataService.send({name: WSEventName.clientUpdated, data: {name: this.name, color: this.color}}) @@ -97,7 +111,9 @@ export class SettingsModalComponent implements OnInit { this.maxVideoSize = parseInt(localStorage.getItem("maxVideoSize") || "200") this.playVideoAssets = Loader.playsVideoAssets this.softEdges = (localStorage.getItem("softEdges") || "true") == "true" - this.tokenId = localStorage.getItem("userTokenId") ?? undefined + const storedToken = localStorage.getItem("userTokenId") + this.tokenId = storedToken && storedToken !== "null" && storedToken !== "undefined" ? storedToken : null this.runMode = this.state.runMode + this.appearance = storedAppearance() } } diff --git a/src/app/core/toolbar/toolbar.component.html b/src/app/core/toolbar/toolbar.component.html index 1fc4371..cc14000 100644 --- a/src/app/core/toolbar/toolbar.component.html +++ b/src/app/core/toolbar/toolbar.component.html @@ -1,7 +1,7 @@ diff --git a/src/app/draggable.directive.ts b/src/app/draggable.directive.ts index c1b2b93..ebb5fd1 100644 --- a/src/app/draggable.directive.ts +++ b/src/app/draggable.directive.ts @@ -1,4 +1,4 @@ -import { Directive,ElementRef,HostListener,AfterViewInit } from "@angular/core"; +import { Directive, ElementRef, HostListener, AfterViewInit } from "@angular/core"; @Directive({ selector: "[appDraggable]", @@ -17,46 +17,69 @@ export class DraggableDirective implements AfterViewInit { let element = this.element.nativeElement; this.handleElement = this.element.nativeElement; this.handleElement.style.cursor = "move"; - this.modalElement = element.closest(".modal-content"); + this.handleElement.style.touchAction = "none"; + this.modalElement = element.closest(".modal-content, .floating-window"); } - @HostListener("mousedown", ["$event"]) - public onMouseDown(event: MouseEvent) { - if (event.button === 2 || !this.handleElement) { - return; // prevents right click drag or initialized handleElement + @HostListener("pointerdown", ["$event"]) + public onPointerDown(event: PointerEvent) { + if (event.button !== 0 || !this.handleElement || !this.modalElement) { + return; } if (event.target !== this.handleElement && !this.searchParentNode(event.target, this.handleElement)) { return; // prevents dragging of other elements than children of handleElement } + const target = event.target as Element | null; + if (target?.closest('button, a, input, select, textarea')) return; + //enable dragging this.isDraggable = true; //store original position - this.topStart = event.clientY - Number(this.modalElement.style.top.replace('px', '')); - this.leftStart = event.clientX - Number(this.modalElement.style.left.replace('px', '')); + const bounds = this.modalElement.getBoundingClientRect(); + this.topStart = event.clientY - bounds.top; + this.leftStart = event.clientX - bounds.left; + + // Preserve the rendered position before replacing CSS anchors such as + // `right: 8px`. Otherwise a right-anchored window jumps left as soon as + // its header is pressed, even when the pointer never moves. + this.modalElement.style.top = `${bounds.top}px`; + this.modalElement.style.left = `${bounds.left}px`; + this.modalElement.style.right = 'auto'; + this.modalElement.style.bottom = 'auto'; + this.handleElement.setPointerCapture(event.pointerId); event.preventDefault(); -} + } - @HostListener("mouseup", ["$event"]) - public onMouseUp(event: MouseEvent) { + @HostListener("pointerup", ["$event"]) + @HostListener("pointercancel", ["$event"]) + public onPointerUp(event: PointerEvent) { this.isDraggable = false; + if (this.handleElement?.hasPointerCapture(event.pointerId)) { + this.handleElement.releasePointerCapture(event.pointerId); + } } - @HostListener("mousemove", ["$event"]) - public onMouseMove(event: MouseEvent) { + @HostListener("pointermove", ["$event"]) + public onPointerMove(event: PointerEvent) { if (this.isDraggable) { - this.modalElement.style.top = event.clientY - this.topStart + "px"; - this.modalElement.style.left = event.clientX - this.leftStart + "px"; + const bounds = this.modalElement.getBoundingClientRect(); + const minimumVisible = 72; + const left = Math.min( + window.innerWidth - minimumVisible, + Math.max(minimumVisible - bounds.width, event.clientX - this.leftStart), + ); + const top = Math.min( + window.innerHeight - 44, + Math.max(0, event.clientY - this.topStart), + ); + this.modalElement.style.top = `${top}px`; + this.modalElement.style.left = `${left}px`; } } - @HostListener("mouseleave", ["$event"]) - public onMouseLeave(event: MouseEvent) { - this.isDraggable = false; - } - private searchParentNode(element: Node, tag: Node): Node | null { while (element.parentNode) { element = element.parentNode; diff --git a/src/app/shared/appearance.spec.ts b/src/app/shared/appearance.spec.ts new file mode 100644 index 0000000..3520e75 --- /dev/null +++ b/src/app/shared/appearance.spec.ts @@ -0,0 +1,39 @@ +import { + Appearance, + APPEARANCE_STORAGE_KEY, + applyAppearance, + normalizeAppearance, + saveAppearance, + storedAppearance, +} from './appearance'; + +describe('appearance preferences', () => { + let root: HTMLElement; + + beforeEach(() => { + localStorage.clear(); + root = document.createElement('div'); + }); + + it('defaults unknown and missing preferences to automatic', () => { + expect(normalizeAppearance(null)).toBe(Appearance.automatic); + expect(normalizeAppearance('sepia')).toBe(Appearance.automatic); + expect(storedAppearance()).toBe(Appearance.automatic); + }); + + it('uses no override attribute in automatic mode', () => { + root.setAttribute('data-theme', 'dark'); + applyAppearance(Appearance.automatic, root); + expect(root.hasAttribute('data-theme')).toBeFalse(); + }); + + it('stores and applies explicit light and dark modes', () => { + saveAppearance(Appearance.dark, localStorage, root); + expect(localStorage.getItem(APPEARANCE_STORAGE_KEY)).toBe(Appearance.dark); + expect(root.getAttribute('data-theme')).toBe('dark'); + + saveAppearance(Appearance.light, localStorage, root); + expect(storedAppearance()).toBe(Appearance.light); + expect(root.getAttribute('data-theme')).toBe('light'); + }); +}); diff --git a/src/app/shared/appearance.ts b/src/app/shared/appearance.ts new file mode 100644 index 0000000..031a7ac --- /dev/null +++ b/src/app/shared/appearance.ts @@ -0,0 +1,37 @@ +export enum Appearance { + automatic = "automatic", + light = "light", + dark = "dark", +} + +export const APPEARANCE_STORAGE_KEY = "appearance"; + +export function normalizeAppearance(value: string | null | undefined): Appearance { + return Object.values(Appearance).includes(value as Appearance) + ? value as Appearance + : Appearance.automatic; +} + +export function storedAppearance(storage: Storage = localStorage): Appearance { + return normalizeAppearance(storage.getItem(APPEARANCE_STORAGE_KEY)); +} + +export function applyAppearance( + appearance: Appearance, + root: HTMLElement = document.documentElement, +): void { + if (appearance === Appearance.automatic) { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", appearance); + } +} + +export function saveAppearance( + appearance: Appearance, + storage: Storage = localStorage, + root: HTMLElement = document.documentElement, +): void { + storage.setItem(APPEARANCE_STORAGE_KEY, appearance); + applyAppearance(appearance, root); +} diff --git a/src/app/shared/area-template-tools.spec.ts b/src/app/shared/area-template-tools.spec.ts new file mode 100644 index 0000000..b8bbe7c --- /dev/null +++ b/src/app/shared/area-template-tools.spec.ts @@ -0,0 +1,77 @@ +import { AreaEffectShape } from './models/area-effect'; +import { affectedSquareCenters, areaTemplateDimensions } from './area-template-tools'; + +describe('area template tools', () => { + it('uses the drag distance as a radius for centered shapes', () => { + for (const shape of [AreaEffectShape.sphere, AreaEffectShape.cylinder]) { + const dimensions = areaTemplateDimensions(shape, { x: 10, y: 20 }, { x: 40, y: 60 }, 50); + expect(dimensions.radius).toBe(50); + expect(dimensions.length).toBe(50); + } + }); + + it('uses the origin-to-edge drag for cones and cubes', () => { + for (const shape of [AreaEffectShape.cone, AreaEffectShape.cube]) { + const dimensions = areaTemplateDimensions(shape, { x: 5, y: 5 }, { x: 5, y: 35 }, 40); + expect(dimensions.length).toBe(30); + expect(dimensions.angle).toBeCloseTo(Math.PI / 2); + expect(dimensions.radius).toBe(0); + } + }); + + it('keeps lines one grid cell wide', () => { + const dimensions = areaTemplateDimensions(AreaEffectShape.line, { x: 0, y: 0 }, { x: 30, y: 40 }, 60); + expect(dimensions.length).toBe(50); + expect(dimensions.width).toBe(60); + }); + + it('highlights every square touched by a centered radius', () => { + const centers = affectedSquareCenters( + AreaEffectShape.sphere, + { x: 50, y: 50 }, + { x: 101, y: 50 }, + 100, + 0, + 0, + 400, + 400 + ); + expect(centers).toContain(jasmine.objectContaining({ x: 50, y: 50 })); + expect(centers).toContain(jasmine.objectContaining({ x: 150, y: 50 })); + expect(centers).toContain(jasmine.objectContaining({ x: 50, y: 150 })); + expect(centers.length).toBe(3); + }); + + it('does not count a square touched only at its boundary', () => { + const centers = affectedSquareCenters( + AreaEffectShape.line, + { x: 0, y: 50 }, + { x: 200, y: 50 }, + 100, + 0, + 0, + 400, + 400 + ); + expect(centers).toEqual([ + { x: 50, y: 50 }, + { x: 150, y: 50 } + ]); + }); + + it('includes the cone origin square and squares crossed by its fan', () => { + const centers = affectedSquareCenters( + AreaEffectShape.cone, + { x: 50, y: 50 }, + { x: 250, y: 50 }, + 100, + 0, + 0, + 400, + 400 + ); + expect(centers).toContain(jasmine.objectContaining({ x: 50, y: 50 })); + expect(centers).toContain(jasmine.objectContaining({ x: 150, y: 50 })); + expect(centers).toContain(jasmine.objectContaining({ x: 250, y: 50 })); + }); +}); diff --git a/src/app/shared/area-template-tools.ts b/src/app/shared/area-template-tools.ts new file mode 100644 index 0000000..a29e137 --- /dev/null +++ b/src/app/shared/area-template-tools.ts @@ -0,0 +1,197 @@ +import { AreaEffectShape } from './models/area-effect'; + +export interface AreaTemplatePoint { + x: number; + y: number; +} + +export interface AreaTemplateDimensions { + angle: number; + length: number; + radius: number; + width: number; +} + +interface Rectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * Converts a player's origin-to-edge drag into Encounter+'s native area-effect + * dimensions. The native renderer provides the important anchor semantics: + * circles use their center, cones use their point, and cubes/lines use the + * midpoint of their starting edge. + */ +export function areaTemplateDimensions( + shape: AreaEffectShape, + start: AreaTemplatePoint, + end: AreaTemplatePoint, + lineWidth: number +): AreaTemplateDimensions { + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + const distance = Math.hypot(deltaX, deltaY); + + return { + angle: Math.atan2(deltaY, deltaX), + length: distance, + radius: shape == AreaEffectShape.sphere || shape == AreaEffectShape.cylinder ? distance : 0, + width: Math.max(1, lineWidth) + }; +} + +function rotatePoint(point: AreaTemplatePoint, origin: AreaTemplatePoint, angle: number): AreaTemplatePoint { + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + return { + x: origin.x + (point.x * cosine) - (point.y * sine), + y: origin.y + (point.x * sine) + (point.y * cosine) + }; +} + +function templatePolygon( + shape: AreaEffectShape, + start: AreaTemplatePoint, + dimensions: AreaTemplateDimensions +): AreaTemplatePoint[] { + if (shape == AreaEffectShape.cone) { + const points: AreaTemplatePoint[] = [start]; + const halfAngle = 26.5 * Math.PI / 180; + const segments = 32; + for (let index = 0; index <= segments; index++) { + const angle = dimensions.angle - halfAngle + ((halfAngle * 2 * index) / segments); + points.push({ + x: start.x + dimensions.length * Math.cos(angle), + y: start.y + dimensions.length * Math.sin(angle) + }); + } + return points; + } + + const halfWidth = shape == AreaEffectShape.cube ? dimensions.length / 2 : dimensions.width / 2; + return [ + rotatePoint({ x: 0, y: -halfWidth }, start, dimensions.angle), + rotatePoint({ x: dimensions.length, y: -halfWidth }, start, dimensions.angle), + rotatePoint({ x: dimensions.length, y: halfWidth }, start, dimensions.angle), + rotatePoint({ x: 0, y: halfWidth }, start, dimensions.angle) + ]; +} + +function pointInRectangle(point: AreaTemplatePoint, rectangle: Rectangle): boolean { + return point.x > rectangle.left && point.x < rectangle.right && point.y > rectangle.top && point.y < rectangle.bottom; +} + +function pointInPolygon(point: AreaTemplatePoint, polygon: AreaTemplatePoint[]): boolean { + let inside = false; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + const a = polygon[current]; + const b = polygon[previous]; + if (((a.y > point.y) != (b.y > point.y)) && + point.x < ((b.x - a.x) * (point.y - a.y) / (b.y - a.y)) + a.x) { + inside = !inside; + } + } + return inside; +} + +function direction(a: AreaTemplatePoint, b: AreaTemplatePoint, c: AreaTemplatePoint): number { + return ((c.x - a.x) * (b.y - a.y)) - ((b.x - a.x) * (c.y - a.y)); +} + +function segmentsCross(a: AreaTemplatePoint, b: AreaTemplatePoint, c: AreaTemplatePoint, d: AreaTemplatePoint): boolean { + const abC = direction(a, b, c); + const abD = direction(a, b, d); + const cdA = direction(c, d, a); + const cdB = direction(c, d, b); + return ((abC > 0 && abD < 0) || (abC < 0 && abD > 0)) && + ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0)); +} + +function polygonIntersectsRectangle(polygon: AreaTemplatePoint[], rectangle: Rectangle): boolean { + if (polygon.some(point => pointInRectangle(point, rectangle))) return true; + + const corners = [ + { x: rectangle.left, y: rectangle.top }, + { x: rectangle.right, y: rectangle.top }, + { x: rectangle.right, y: rectangle.bottom }, + { x: rectangle.left, y: rectangle.bottom } + ]; + if (corners.some(point => pointInPolygon(point, polygon))) return true; + + for (let polygonIndex = 0; polygonIndex < polygon.length; polygonIndex++) { + const polygonStart = polygon[polygonIndex]; + const polygonEnd = polygon[(polygonIndex + 1) % polygon.length]; + for (let rectangleIndex = 0; rectangleIndex < corners.length; rectangleIndex++) { + if (segmentsCross(polygonStart, polygonEnd, corners[rectangleIndex], corners[(rectangleIndex + 1) % corners.length])) { + return true; + } + } + } + return false; +} + +function circleIntersectsRectangle(center: AreaTemplatePoint, radius: number, rectangle: Rectangle): boolean { + const closestX = Math.max(rectangle.left, Math.min(center.x, rectangle.right)); + const closestY = Math.max(rectangle.top, Math.min(center.y, rectangle.bottom)); + return Math.hypot(center.x - closestX, center.y - closestY) < radius; +} + +/** Returns the center of every square with a non-zero overlap with the template. */ +export function affectedSquareCenters( + shape: AreaEffectShape, + start: AreaTemplatePoint, + end: AreaTemplatePoint, + gridSize: number, + offsetX: number, + offsetY: number, + maximumWidth = Number.POSITIVE_INFINITY, + maximumHeight = Number.POSITIVE_INFINITY +): AreaTemplatePoint[] { + if (!Number.isFinite(gridSize) || gridSize <= 0) return []; + + const dimensions = areaTemplateDimensions(shape, start, end, gridSize); + if (dimensions.length <= 0) return []; + + const polygon = shape == AreaEffectShape.sphere || shape == AreaEffectShape.cylinder + ? null + : templatePolygon(shape, start, dimensions); + const extent = shape == AreaEffectShape.cube + ? Math.hypot(dimensions.length, dimensions.length / 2) + : shape == AreaEffectShape.line + ? Math.hypot(dimensions.length, dimensions.width / 2) + : dimensions.length; + const minX = Math.max(0, start.x - extent); + const maxX = Math.min(maximumWidth, start.x + extent); + const minY = Math.max(0, start.y - extent); + const maxY = Math.min(maximumHeight, start.y + extent); + const startColumn = Math.max(0, Math.floor((minX - offsetX) / gridSize)); + const endColumn = Math.floor((maxX - offsetX) / gridSize); + const startRow = Math.max(0, Math.floor((minY - offsetY) / gridSize)); + const endRow = Math.floor((maxY - offsetY) / gridSize); + const centers: AreaTemplatePoint[] = []; + const inset = Math.max(0.001, gridSize * 0.00001); + + for (let column = startColumn; column <= endColumn; column++) { + for (let row = startRow; row <= endRow; row++) { + const left = offsetX + column * gridSize; + const top = offsetY + row * gridSize; + const rectangle = { + left: left + inset, + top: top + inset, + right: left + gridSize - inset, + bottom: top + gridSize - inset + }; + const affected = polygon + ? polygonIntersectsRectangle(polygon, rectangle) + : circleIntersectsRectangle(start, dimensions.radius, rectangle); + if (affected) { + centers.push({ x: left + gridSize / 2, y: top + gridSize / 2 }); + } + } + } + + return centers; +} diff --git a/src/app/shared/entity-frame-interactions.spec.ts b/src/app/shared/entity-frame-interactions.spec.ts new file mode 100644 index 0000000..af3e4af --- /dev/null +++ b/src/app/shared/entity-frame-interactions.spec.ts @@ -0,0 +1,61 @@ +import { entityFrameAction, rollCommand } from './entity-frame-interactions'; + +describe('entity frame interactions', () => { + function anchor(href: string, text: string, title?: string): HTMLAnchorElement { + const link = document.createElement('a'); + link.href = href; + link.setAttribute('href', href); + link.textContent = text; + if (title) link.title = title; + return link; + } + + it('turns a linked modifier into a named d20 roll', () => { + const action = entityFrameAction(anchor('roll', '+7', 'Jaws/attack'), 'http://127.0.0.1:8080'); + + expect(action).toEqual({ kind: 'roll', formula: '1d20+7', name: 'Jaws', rollType: 'attack' }); + expect(rollCommand(action as any)).toBe('/r 1d20+7 [Jaws:attack]'); + }); + + it('uses a dice formula embedded in a roll path', () => { + const action = entityFrameAction(anchor('/roll/d20%2B3', '+3', 'Perception'), 'http://127.0.0.1:8080'); + expect(action).toEqual({ kind: 'roll', formula: '1d20+3', name: 'Perception' }); + }); + + it('recognizes Encounter+ roll links with route metadata or data attributes', () => { + const routed = entityFrameAction( + anchor('/entity/roll/2d6%2B4/Fireball/damage', '2d6+4'), + 'http://127.0.0.1:8080', + ); + expect(routed).toEqual({ kind: 'roll', formula: '2d6+4', name: 'Fireball', rollType: 'damage' }); + + const attributed = anchor('#', '+5'); + attributed.dataset['action'] = 'roll'; + attributed.dataset['name'] = 'Stealth/check'; + expect(entityFrameAction(attributed, 'http://127.0.0.1:8080')).toEqual({ + kind: 'roll', + formula: '1d20+5', + name: 'Stealth', + rollType: 'check', + }); + }); + + it('opens same-host rules as references', () => { + const action = entityFrameAction( + anchor('/condition/frightened-player-core', 'Frightened'), + 'http://127.0.0.1:8080', + ); + expect(action).toEqual({ + kind: 'reference', + reference: '/condition/frightened-player-core', + title: 'Frightened', + }); + }); + + it('leaves external and new-window links alone', () => { + expect(entityFrameAction(anchor('https://example.com/rule', 'Rule'), 'http://127.0.0.1:8080')).toBeUndefined(); + const image = anchor('/data/token.png', 'Image'); + image.target = '_blank'; + expect(entityFrameAction(image, 'http://127.0.0.1:8080')).toBeUndefined(); + }); +}); diff --git a/src/app/shared/entity-frame-interactions.ts b/src/app/shared/entity-frame-interactions.ts new file mode 100644 index 0000000..1fff220 --- /dev/null +++ b/src/app/shared/entity-frame-interactions.ts @@ -0,0 +1,119 @@ +export type EntityFrameAction = EntityRollAction | EntityReferenceAction; + +export interface EntityRollAction { + kind: 'roll'; + formula: string; + name: string; + rollType?: 'check' | 'save' | 'attack' | 'damage'; +} + +export interface EntityReferenceAction { + kind: 'reference'; + reference: string; + title: string; +} + +const ROLL_TYPES = new Set(['check', 'save', 'attack', 'damage']); + +function rollFormula(value: string): string | undefined { + const normalized = value + .trim() + .replace(/[\u2212\u2013\u2014]/g, '-') + .replace(/\s+/g, ''); + + if (/^[+-]?\d+$/.test(normalized)) { + const modifier = Number(normalized); + if (!Number.isFinite(modifier)) return undefined; + return modifier > 0 ? `1d20+${modifier}` : modifier < 0 ? `1d20${modifier}` : '1d20'; + } + + if (/^d\d+/i.test(normalized)) return `1${normalized}`; + if (/^\d+d\d+/i.test(normalized)) return normalized; + return undefined; +} + +function rollLabel(value?: string | null): { name: string; rollType?: EntityRollAction['rollType'] } { + const label = (value || '').trim(); + const segments = label.split('/'); + const possibleType = segments.length > 1 ? segments[segments.length - 1].toLowerCase() : ''; + + if (ROLL_TYPES.has(possibleType)) { + return { + name: segments.slice(0, -1).join('/').trim() || 'Custom', + rollType: possibleType as EntityRollAction['rollType'], + }; + } + + return { name: label || 'Custom' }; +} + +export function entityFrameAction(anchor: HTMLAnchorElement, appBaseURL: string): EntityFrameAction | undefined { + const rawHref = (anchor.getAttribute('href') || '').trim(); + const decodedHref = (() => { + try { return decodeURIComponent(rawHref); } catch { return rawHref; } + })(); + const data = anchor.dataset; + let destination: URL | undefined; + try { + destination = rawHref ? new URL(rawHref, `${new URL(appBaseURL).origin}/`) : undefined; + } catch { + destination = undefined; + } + + const pathSegments = destination?.pathname.split('/').filter(Boolean) || []; + const rollIndex = pathSegments.findIndex(segment => segment.toLowerCase() === 'roll'); + const inlineRoll = decodedHref.match(/(?:^|[/:])roll(?:[/:\s]+([^?#\s"']+))?/i); + const isRoll = rollIndex >= 0 + || Boolean(inlineRoll) + || data['action']?.toLowerCase() === 'roll' + || anchor.classList.contains('roll') + || anchor.classList.contains('rollable') + || data['roll'] !== undefined + || data['formula'] !== undefined; + + if (isRoll) { + const encodedPathFormula = rollIndex >= 0 ? pathSegments[rollIndex + 1] || '' : inlineRoll?.[1] || ''; + const pathFormula = (() => { + try { return decodeURIComponent(encodedPathFormula); } catch { return encodedPathFormula; } + })(); + const queryFormula = destination?.searchParams.get('formula') || destination?.searchParams.get('dice') || ''; + const formula = rollFormula(data['formula'] || data['roll'] || queryFormula) + || rollFormula(pathFormula) + || rollFormula(anchor.textContent || ''); + if (!formula) return undefined; + + const pathLabel = rollIndex >= 0 && pathSegments.length > rollIndex + 2 + ? pathSegments.slice(rollIndex + 2).join('/') + : undefined; + const label = data['name'] || data['label'] || data['rollName'] || anchor.getAttribute('title') || pathLabel; + + return { + kind: 'roll', + formula, + ...rollLabel(label), + }; + } + + if (!rawHref || rawHref.startsWith('#') || /^javascript:/i.test(rawHref)) return undefined; + if (anchor.target && anchor.target.toLowerCase() !== '_self') return undefined; + + try { + const base = new URL(appBaseURL); + const referenceDestination = destination || new URL(rawHref, `${base.origin}/`); + if (referenceDestination.origin !== base.origin) return undefined; + + return { + kind: 'reference', + reference: `${referenceDestination.pathname}${referenceDestination.search}${referenceDestination.hash}`, + title: (anchor.textContent || '').trim() || 'Reference', + }; + } catch { + return undefined; + } +} + +export function rollCommand(action: EntityRollAction): string { + const safeName = action.name.replace(/[\[\]]/g, '').trim() || 'Custom'; + const label = action.rollType ? `${safeName}:${action.rollType}` : safeName; + return `/r ${action.formula} [${label}]`; +} diff --git a/src/app/shared/measurement-tools.spec.ts b/src/app/shared/measurement-tools.spec.ts new file mode 100644 index 0000000..cbb3e25 --- /dev/null +++ b/src/app/shared/measurement-tools.spec.ts @@ -0,0 +1,50 @@ +import { alternatingDiagonalMovement, formatMeasurementDistance, measurementDistance } from './measurement-tools'; +import { MeasurementView } from '../core/map/views/measurement-view'; +import { SquareGrid } from '../core/map/models/square-grid'; +import { minimalMeasurement } from './models/testing/fixtures'; + +describe('measurement tools', () => { + it('converts a precise pixel distance using the map grid scale', () => { + expect(measurementDistance([0, 0, 30, 40], 10)).toBe(5); + }); + + it('adds every segment in a multi-point measurement', () => { + expect(measurementDistance([0, 0, 30, 40, 60, 80], 10)).toBe(10); + }); + + it('formats whole and tenth-unit distances without unnecessary zeroes', () => { + expect(formatMeasurementDistance(5, 'ft')).toBe('5 ft'); + expect(formatMeasurementDistance(40.66, 'ft')).toBe('40.7 ft'); + }); + + it('handles incomplete measurements safely', () => { + expect(measurementDistance([0, 0], 10)).toBe(0); + expect(measurementDistance([0, 0, 10, 10], 0)).toBe(0); + }); + + it('refreshes rendered distances when the map grid is replaced', async () => { + const view = new MeasurementView(minimalMeasurement({ data: [0, 0, 100, 0] }), new SquareGrid()); + await view.draw(); + expect(view.distanceText.text).toBe('10 ft'); + const grid = new SquareGrid(); + grid.scale = 10; + view.grid = grid; + await view.draw(); + expect(view.distanceText.text).toBe('20 ft'); + view.destroy({ children: true }); + }); + + it('uses alternating 5-foot and 10-foot diagonal movement', () => { + expect(alternatingDiagonalMovement([{ horizontal: 1, vertical: 1 }])).toBe(1); + expect(alternatingDiagonalMovement([{ horizontal: 2, vertical: 2 }])).toBe(3); + expect(alternatingDiagonalMovement([{ horizontal: 3, vertical: 3 }])).toBe(4); + expect(alternatingDiagonalMovement([{ horizontal: 4, vertical: 4 }])).toBe(6); + }); + + it('continues the diagonal alternation across multiple segments', () => { + expect(alternatingDiagonalMovement([ + { horizontal: 1, vertical: 1 }, + { horizontal: 1, vertical: 1 } + ])).toBe(3); + }); +}); diff --git a/src/app/shared/measurement-tools.ts b/src/app/shared/measurement-tools.ts new file mode 100644 index 0000000..d38fe93 --- /dev/null +++ b/src/app/shared/measurement-tools.ts @@ -0,0 +1,43 @@ +export function measurementDistance(data: Array, pixelRatio: number): number { + if (!Array.isArray(data) || data.length < 4 || !Number.isFinite(pixelRatio) || pixelRatio <= 0) { + return 0; + } + + let pixels = 0; + for (let i = 2; i + 1 < data.length; i += 2) { + const dx = data[i] - data[i - 2]; + const dy = data[i + 1] - data[i - 1]; + pixels += Math.hypot(dx, dy); + } + + return pixels / pixelRatio; +} + +export function formatMeasurementDistance(distance: number, units: string): string { + const rounded = Math.round(Math.max(0, distance) * 10) / 10; + const value = Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1); + return `${value} ${units || ''}`.trim(); +} + +export interface GridMovementSegment { + horizontal: number; + vertical: number; +} + +export function alternatingDiagonalMovement(segments: Array): number { + let straightSteps = 0; + let diagonalSteps = 0; + + for (const segment of segments) { + const horizontal = Math.abs(Math.round(segment.horizontal)); + const vertical = Math.abs(Math.round(segment.vertical)); + const diagonal = Math.min(horizontal, vertical); + + diagonalSteps += diagonal; + straightSteps += Math.max(horizontal, vertical) - diagonal; + } + + // PF2E alternates diagonal movement between one and two grid steps: + // 5 feet, then 10 feet, then 5 feet, and so on. + return straightSteps + diagonalSteps + Math.floor(diagonalSteps / 2); +} diff --git a/src/app/shared/models/combatant.ts b/src/app/shared/models/combatant.ts index cdffaad..198e4b3 100644 --- a/src/app/shared/models/combatant.ts +++ b/src/app/shared/models/combatant.ts @@ -20,7 +20,7 @@ export interface Combatant { rank: number initiative?: Array - effects?: Array + effects?: Array | null bloodied?: boolean defeated?: boolean diff --git a/src/app/shared/models/initiative.ts b/src/app/shared/models/initiative.ts index cd29d16..7584802 100644 --- a/src/app/shared/models/initiative.ts +++ b/src/app/shared/models/initiative.ts @@ -2,6 +2,6 @@ export interface Initiative { id: string name?: string - value?: number + value?: number | null order?: number } diff --git a/src/app/shared/models/status-effect.ts b/src/app/shared/models/status-effect.ts index 94f963f..52d3392 100644 --- a/src/app/shared/models/status-effect.ts +++ b/src/app/shared/models/status-effect.ts @@ -7,4 +7,7 @@ export interface StatusEffect { /** A game icon name (`gi-poisoned`), or the path of an image in a system, module or campaign. */ icon?: string enabled?: boolean + /** Optional rules text and system-specific values exposed by the host. */ + descr?: string + data?: Record } diff --git a/src/app/shared/models/wsevent.ts b/src/app/shared/models/wsevent.ts index 5aab620..3962182 100644 --- a/src/app/shared/models/wsevent.ts +++ b/src/app/shared/models/wsevent.ts @@ -7,6 +7,7 @@ export enum WSEventName { mapFitScreen = "mapFitScreen", mapFocus = "mapFocus", combatantUpdated = "combatantUpdated", + updateCombatant = "updateCombatant", tileUpdated = "tileUpdated", tilesUpdated = "tilesUpdated", lightUpdated = "lightUpdated", diff --git a/src/app/shared/player-tools.spec.ts b/src/app/shared/player-tools.spec.ts new file mode 100644 index 0000000..72d110d --- /dev/null +++ b/src/app/shared/player-tools.spec.ts @@ -0,0 +1,163 @@ +import { AppState } from './models/app-state'; +import { Role } from './models/token'; +import { minimalCombatant, minimalToken } from './models/testing/fixtures'; +import { + assignedPlayerCombatant, + assignedPlayerReference, + assignedPlayerToken, + combatantWithHitPoints, + combatantWithInitiative, + playerEffects, +} from './player-tools'; + +describe('player tools', () => { + function buildState(role: Role = Role.friendly) { + const state = new AppState(); + const embedded = minimalCombatant({ + id: 'combatant-1', + tokenId: 'token-1', + reference: '/embedded-sheet', + }); + const live = minimalCombatant({ + id: 'combatant-1', + tokenId: 'token-1', + reference: '/live-sheet', + }); + const token = minimalToken({ + id: 'token-1', + role, + reference: '/token-sheet', + combatant: embedded, + }); + state.userTokenId = token.id; + state.map = { tokens: [token] } as any; + state.game.combatants = [live]; + return { state, live }; + } + + it('only resolves an assigned friendly token', () => { + expect(assignedPlayerToken(buildState().state)?.id).toBe('token-1'); + expect(assignedPlayerToken(buildState(Role.hostile).state)).toBeUndefined(); + }); + + it('prefers the live encounter combatant and its sheet', () => { + const { state, live } = buildState(); + expect(assignedPlayerCombatant(state)).toBe(live); + expect(assignedPlayerReference(state)).toBe('/live-sheet'); + }); + + it('keeps the token sheet available before the character enters combat', () => { + const { state } = buildState(); + state.game.combatants = []; + state.map!.tokens[0].combatant = undefined; + expect(assignedPlayerCombatant(state)).toBeUndefined(); + expect(assignedPlayerReference(state)).toBe('/token-sheet'); + }); + + it('clamps HP while preserving the rest of combatant data', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + data: { hp: { current: 8, maximum: 12, temporary: 2, recovery: 7 }, note: 'keep' }, + }); + const patch = combatantWithHitPoints(combatant, 99, -4); + expect(patch.data.hp).toEqual({ current: 12, maximum: 12, temporary: 0, recovery: 7 }); + expect(patch.data.note).toBe('keep'); + }); + + it('updates only the first initiative result and preserves its metadata', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + initiative: [ + { id: 'initiative-1', name: 'Perception', value: 10, order: 3 }, + { id: 'initiative-2', name: 'Stealth', value: 8 }, + ], + }); + const patch = combatantWithInitiative(combatant, 17.9); + expect(patch.initiative).toEqual([ + { id: 'initiative-1', name: 'Perception', value: 17, order: 3 }, + { id: 'initiative-2', name: 'Stealth', value: 8 }, + ]); + }); + + it('does not turn a missing initiative value into zero', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + initiative: [{ id: 'initiative-1', value: null }], + }); + expect(combatantWithInitiative(combatant, Number.NaN).initiative).toEqual([ + { id: 'initiative-1', value: null }, + ]); + }); + + it('creates the first initiative entry when Encounter+ has not created one yet', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + initiative: [], + }); + expect(combatantWithInitiative(combatant, 14.8, 'new-initiative')).toEqual({ + id: 'combatant-1', + initiative: [{ id: 'new-initiative', value: 14 }], + }); + }); + + it('normalizes valued and descriptive effects without inventing missing data', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + effects: [ + { + id: 'frightened', + name: 'Frightened', + icon: 'icons/conditions.png', + color: '#5B5F97', + reference: '/condition/frightened-player-core', + data: { stage: 2 }, + }, + { id: 'persistent-fire', name: 'Persistent Fire', data: { damage: { formula: '1d6' } }, descr: 'Ongoing fire damage.' }, + ], + }); + + expect(playerEffects(combatant)).toEqual([ + jasmine.objectContaining({ + id: 'frightened', + name: 'Frightened', + value: '2', + reference: '/condition/frightened-player-core', + }), + jasmine.objectContaining({ + name: 'Persistent Fire', + detail: '1d6', + description: 'Ongoing fire damage.', + }), + ]); + }); + + it('accepts the alternate effect dictionary shape', () => { + const combatant = minimalCombatant({ + id: 'combatant-1', + data: { effects: { hidden: { label: 'Invisible' } } }, + }); + expect(playerEffects(combatant).map(effect => effect.name)).toEqual(['Invisible']); + }); + + it('excludes disabled effects consistently with token rendering', () => { + const combatant = minimalCombatant({ effects: [ + { id: 'active', name: 'Active' }, + { id: 'disabled', name: 'Disabled', enabled: false }, + ] }); + expect(playerEffects(combatant).map(effect => effect.name)).toEqual(['Active']); + }); + + it('does not revive nested effects after the server explicitly clears the top-level field', () => { + const staleNestedEffect = { hidden: { label: 'Invisible' } }; + + expect(playerEffects(minimalCombatant({ + effects: null, + data: { effects: staleNestedEffect }, + }))).toEqual([]); + + expect(playerEffects(minimalCombatant({ + effects: [], + data: { effects: staleNestedEffect }, + }))).toEqual([]); + }); +}); diff --git a/src/app/shared/player-tools.ts b/src/app/shared/player-tools.ts new file mode 100644 index 0000000..a950c13 --- /dev/null +++ b/src/app/shared/player-tools.ts @@ -0,0 +1,159 @@ +import { AppState } from './models/app-state'; +import { Combatant } from './models/combatant'; +import { Role, Token } from './models/token'; +import { v4 as uuidv4 } from 'uuid'; + +export interface HitPointRange { + current: number; + maximum: number; + temporary: number; +} + +export interface PlayerEffect { + id: string; + name: string; + value?: string; + detail?: string; + icon?: string; + color?: string; + reference?: string; + description?: string; +} + +function textValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return undefined; +} + +function effectColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-f]{3,8}$/i.test(value.trim()) ? value.trim() : undefined; +} + +export function playerEffects(combatant?: Combatant): PlayerEffect[] { + // An explicit top-level null/empty value means the server removed every effect. Only use the + // older nested representation when the top-level field is genuinely absent; `??` would revive + // stale nested effects after the last top-level effect was removed. + const hasTopLevelEffects = Boolean(combatant) + && Object.prototype.hasOwnProperty.call(combatant, 'effects'); + const source = hasTopLevelEffects ? combatant?.effects : combatant?.data?.effects; + const effects: unknown[] = Array.isArray(source) + ? source + : source && typeof source === 'object' + ? Object.values(source) + : []; + + return effects.flatMap((effect, index) => { + if (typeof effect === 'string') { + const name = effect.trim(); + return name ? [{ id: `effect-${index}-${name}`, name }] : []; + } + if (!effect || typeof effect !== 'object') return []; + + const record = effect as Record; + if (record['enabled'] === false) return []; + const data = record['data'] && typeof record['data'] === 'object' ? record['data'] : {}; + const name = textValue(record['name'], record['label'], record['title'], data['name']); + if (!name) return []; + + const stage = textValue(data['stage'], record['stage']); + const rawValue = textValue(record['value'], data['value']); + const value = stage && stage !== '0' ? stage : rawValue && rawValue !== '0' ? rawValue : undefined; + const damage = record['damage'] && typeof record['damage'] === 'object' ? record['damage'] : {}; + const dataDamage = data['damage'] && typeof data['damage'] === 'object' ? data['damage'] : {}; + const detail = textValue(record['formula'], data['formula'], damage['formula'], dataDamage['formula']); + const reference = textValue(record['reference'], data['reference']); + const description = textValue(record['descr'], record['description'], data['descr'], data['description']); + + return [{ + id: textValue(record['id'], record['slug'], reference) || `effect-${index}-${name}`, + name, + value: value && !name.endsWith(` ${value}`) ? value : undefined, + detail: detail !== value ? detail : undefined, + icon: textValue(record['icon'], record['image'], data['icon'], data['image']), + color: effectColor(record['color'] ?? data['color']), + reference, + description, + }]; + }); +} + +export function assignedPlayerToken(state: AppState): Token | undefined { + const storedTokenId = typeof localStorage === 'undefined' ? null : localStorage.getItem('userTokenId'); + const tokenId = state.userTokenId || storedTokenId; + return state.map?.tokens?.find(token => token.id === tokenId && token.role === Role.friendly); +} + +export function assignedPlayerCombatant(state: AppState): Combatant | undefined { + const token = assignedPlayerToken(state); + if (!token) return undefined; + + return state.game?.combatants?.find(combatant => + combatant.tokenId === token.id || combatant.id === token.combatant?.id + ) || token.combatant; +} + +export function assignedPlayerReference(state: AppState): string | undefined { + const token = assignedPlayerToken(state); + return assignedPlayerCombatant(state)?.reference || token?.reference; +} + +export function hitPoints(combatant?: Combatant): HitPointRange | undefined { + const hp = combatant?.data?.hp; + if (!hp || !Number.isFinite(Number(hp.maximum))) return undefined; + + const maximum = Math.max(0, Number(hp.maximum)); + const rawCurrent = Number(hp.current ?? maximum); + const rawTemporary = Number(hp.temporary ?? 0); + + return { + current: Number.isFinite(rawCurrent) ? Math.min(maximum, Math.max(0, rawCurrent)) : maximum, + maximum, + temporary: Number.isFinite(rawTemporary) ? Math.max(0, rawTemporary) : 0, + }; +} + +export function combatantWithHitPoints( + combatant: Combatant, + current: number, + temporary: number, +): Partial { + const hp = hitPoints(combatant); + if (!hp) return { id: combatant.id }; + + const nextCurrent = Number.isFinite(current) + ? Math.min(hp.maximum, Math.max(0, Math.trunc(current))) + : hp.current; + const nextTemporary = Number.isFinite(temporary) + ? Math.max(0, Math.trunc(temporary)) + : hp.temporary; + return { + id: combatant.id, + data: { + ...(combatant.data || {}), + hp: { + ...(combatant.data?.hp || {}), + current: nextCurrent, + temporary: nextTemporary, + }, + }, + }; +} + +export function combatantWithInitiative( + combatant: Combatant, + value: number, + initiativeId?: string, +): Partial { + const current = combatant.initiative || []; + const initiatives = current.length + ? current.map((initiative, index) => + index === 0 && Number.isFinite(value) ? { ...initiative, value: Math.trunc(value) } : { ...initiative } + ) + : Number.isFinite(value) + ? [{ id: initiativeId || uuidv4(), value: Math.trunc(value) }] + : []; + return { id: combatant.id, initiative: initiatives }; +} diff --git a/src/main.ts b/src/main.ts index 9a88549..658eabe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,14 @@ import { enableProdMode, provideZoneChangeDetection } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; +import { applyAppearance, storedAppearance } from './app/shared/appearance'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } +applyAppearance(storedAppearance()); + platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.error(err)); diff --git a/src/styles.scss b/src/styles.scss index 3a83777..0a3ee74 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -10,14 +10,174 @@ $gameboard-margin: 75px; font-display: block; } +:root, +:root[data-theme="light"] { + color-scheme: light; + --ui-page: #f1f3f5; + --ui-surface: rgba(255, 255, 255, 0.96); + --ui-surface-solid: #ffffff; + --ui-surface-muted: #f1f3f5; + --ui-surface-hover: #e9ecef; + --ui-text: #212529; + --ui-text-muted: #5c636a; + --ui-border: #ced4da; + --ui-border-strong: #adb5bd; + --ui-control: #ffffff; + --ui-control-hover: #e9ecef; + --ui-accent: #276da8; + --ui-accent-hover: #1f5a8c; + --ui-accent-text: #ffffff; + --ui-shadow: rgba(0, 0, 0, 0.22); + --ui-backdrop: rgba(0, 0, 0, 0.72); + --ui-close-filter: none; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --ui-page: #111416; + --ui-surface: rgba(29, 33, 36, 0.96); + --ui-surface-solid: #1d2124; + --ui-surface-muted: #292e32; + --ui-surface-hover: #343a40; + --ui-text: #f1f3f5; + --ui-text-muted: #adb5bd; + --ui-border: #495057; + --ui-border-strong: #6c757d; + --ui-control: #292e32; + --ui-control-hover: #3a4147; + --ui-accent: #4b8fca; + --ui-accent-hover: #62a0d5; + --ui-accent-text: #ffffff; + --ui-shadow: rgba(0, 0, 0, 0.55); + --ui-backdrop: rgba(0, 0, 0, 0.82); + --ui-close-filter: invert(1) grayscale(100%) brightness(200%); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; + --ui-page: #111416; + --ui-surface: rgba(29, 33, 36, 0.96); + --ui-surface-solid: #1d2124; + --ui-surface-muted: #292e32; + --ui-surface-hover: #343a40; + --ui-text: #f1f3f5; + --ui-text-muted: #adb5bd; + --ui-border: #495057; + --ui-border-strong: #6c757d; + --ui-control: #292e32; + --ui-control-hover: #3a4147; + --ui-accent: #4b8fca; + --ui-accent-hover: #62a0d5; + --ui-accent-text: #ffffff; + --ui-shadow: rgba(0, 0, 0, 0.55); + --ui-backdrop: rgba(0, 0, 0, 0.82); + --ui-close-filter: invert(1) grayscale(100%) brightness(200%); + } +} + body { padding: 0; margin: 0; - background-color: #333; + color: var(--ui-text); + background-color: var(--ui-page); overflow: hidden; position: relative; } +.btn-client { + color: var(--ui-text); + background: var(--ui-control); + border-color: var(--ui-border); + + .badge { + color: inherit; + } + + &:hover, + &:focus-visible, + &:active, + &.active, + &.show, + .btn-check:checked + & { + color: var(--ui-text); + background: var(--ui-control-hover); + border-color: var(--ui-border-strong); + } +} + +.client-divider-start { + border-left: 1px solid var(--ui-border); +} + +.client-divider-top { + border-top: 1px solid var(--ui-border); +} + +.modal-content, +.dropdown-menu { + color: var(--ui-text); + background: var(--ui-surface-solid); + border-color: var(--ui-border); + box-shadow: 0 0.75rem 2rem var(--ui-shadow); +} + +.modal-header, +.modal-footer { + border-color: var(--ui-border); +} + +.dropdown-item { + color: var(--ui-text); + + &:hover, + &:focus { + color: var(--ui-text); + background: var(--ui-surface-hover); + } +} + +.dropdown-divider { + border-color: var(--ui-border); +} + +.form-control, +.form-select { + color: var(--ui-text); + background-color: var(--ui-control); + border-color: var(--ui-border); + + &:focus { + color: var(--ui-text); + background-color: var(--ui-control); + border-color: var(--ui-accent); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--ui-accent) 25%, transparent); + } +} + +.form-text { + color: var(--ui-text-muted); +} + +.btn-close { + filter: var(--ui-close-filter); +} + +.btn-primary, +.btn-success { + color: var(--ui-accent-text); + background-color: var(--ui-accent); + border-color: var(--ui-accent); + + &:hover, + &:focus-visible, + &:active { + color: var(--ui-accent-text); + background-color: var(--ui-accent-hover); + border-color: var(--ui-accent-hover); + } +} + button[aria-label="Close"]:focus { outline: none; } @@ -34,15 +194,24 @@ button[aria-label="Close"]:focus { code { padding: 2px 4px; - border: 1px solid #000; + border: 1px solid var(--ui-border); border-radius: 4px; - background-color: #131414; + background-color: var(--ui-surface-muted); margin: 4px 0px; display: block; - color: #6e7ed7; + color: var(--ui-accent); } .dark-modal { width: 400px; } +// Settings controls the entire client, so its backdrop and dialog must cover every floating +// character, rule, effect, lightbox, and map overlay created underneath it. +.settings-modal-backdrop { + z-index: 20000 !important; +} + +.settings-modal-layer { + z-index: 20010 !important; +}