Add killerName to Mission model and enhance overlay components

- Introduced killerName field in the Mission model within the Prisma schema and created a corresponding migration.
- Updated mission management logic to assign a killerName during mission creation.
- Enhanced overlay components to display killer information, including avatar and name, in both expanded and minimized panels.
- Added new CSS styles for killer display in the overlay.
- Included new avatar images for various killers in the overlay assets.
This commit is contained in:
Maurycy
2026-05-11 17:39:22 +00:00
parent 0031ef0a8f
commit 0517202412
60 changed files with 372 additions and 127 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT;

View File

@@ -39,6 +39,7 @@ model Mission {
difficulty Int @db.SmallInt
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
status String @default("active")
killerName String? @map("killer_name")
encounterLibraryVersion String @map("encounter_library_version")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")

View File

@@ -79,7 +79,7 @@ export class EncounterService {
const libEncounter = getEncounterById(encounter.key);
const flavor = libEncounter
? pickFlavor(libEncounter, { success: result.success }, rng)
? pickFlavor(libEncounter, { success: result.success, killerName: mission.killerName }, rng)
: result.logText;
newLog.push({ ...result, logText: flavor });

View File

@@ -10,7 +10,8 @@ import type {
Survivor,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
import { getLibraryVersion } from '@fog-explorer/encounter-library';
import { getLibraryVersion, KILLER_NAMES } from '@fog-explorer/encounter-library';
import seedrandom = require('seedrandom');
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
import { PrismaService } from '../prisma/prisma.service';
import { MissionStoreService } from './mission-store.service';
@@ -38,6 +39,7 @@ export class MissionsService {
const nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter);
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
const killerName = pickKiller(missionId);
// Upsert user and create survivor + mission in one transaction.
await this.prisma.$transaction(async (tx) => {
@@ -66,6 +68,7 @@ export class MissionsService {
difficulty,
durationMinutes,
status: 'active',
killerName,
encounterLibraryVersion: getLibraryVersion(),
nextTickAt,
participants: {
@@ -98,6 +101,7 @@ export class MissionsService {
difficulty,
durationMinutes,
status: 'active',
killerName,
encounterLibraryVersion: getLibraryVersion(),
nextTickAt: nextTickAt.toISOString(),
tickIndex: 0,
@@ -154,3 +158,8 @@ export class MissionsService {
function defaultName(opaqueUserId: string): string {
return `Survivor ${opaqueUserId.slice(-4)}`;
}
function pickKiller(missionId: string): string {
const rng = seedrandom(`killer:${missionId}`);
return KILLER_NAMES[Math.floor(rng() * KILLER_NAMES.length)];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -160,11 +160,36 @@
text-transform: uppercase;
}
.killer-vs {
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
}
.killer-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
object-fit: cover;
object-position: top center;
opacity: 0.8;
flex-shrink: 0;
}
.killer-name {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--fog-text-dim);
font-style: italic;
white-space: nowrap;
}
.tick-counter {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-dim);
margin-left: auto;
margin-left: 8px;
}
/* ── Log ── */

View File

@@ -41,6 +41,19 @@
}
</div>
<span class="mission-label">Mission</span>
@if (killerName(); as kn) {
<div class="killer-vs">
@if (killerAvatarSrc() && !killerImgError()) {
<img
class="killer-thumb"
[src]="killerAvatarSrc()!"
[alt]="kn"
(error)="killerImgError.set(true)"
/>
}
<span class="killer-name">vs {{ kn }}</span>
</div>
}
<span class="tick-counter">T+{{ m.tickIndex }}</span>
</div>
}

View File

@@ -7,6 +7,7 @@ import {
signal,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { killerAvatarUrl } from '@fog-explorer/encounter-library';
import { avatarUrl, survivorInitials } from './survivor-initials';
/** Most-recent entry is index 0; older entries fade toward 0 opacity. */
@@ -41,6 +42,15 @@ export class ExpandedPanelComponent {
protected readonly imgError = signal(false);
protected killerName = computed(() => this.missionState().mission.killerName ?? null);
protected killerAvatarSrc = computed(() => {
const name = this.killerName();
return name ? killerAvatarUrl(name) : null;
});
protected readonly killerImgError = signal(false);
protected survivorStateColor = computed(() => {
const state = this.missionState().survivors[0]?.state ?? 'idle';
const map: Record<string, string> = {

View File

@@ -1,12 +1,17 @@
:host { display: block; }
.panel {
display: flex;
flex-direction: column;
width: 290px;
background: var(--fog-bg);
}
.main-row {
display: flex;
align-items: center;
gap: 10px;
width: 290px;
height: 56px;
background: var(--fog-bg);
padding: 0 12px 0 8px;
}
@@ -91,3 +96,33 @@
color: var(--fog-text-dim);
font-style: italic;
}
/* ── Killer strip ── */
.killer-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px 7px 12px;
border-top: 1px solid var(--fog-border);
padding-top: 5px;
}
.killer-thumb {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
object-fit: cover;
object-position: top center;
opacity: 0.75;
}
.killer-label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--fog-text-dim);
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,33 +1,49 @@
<div class="panel">
<div class="lantern-wrap">
<button
class="lantern"
[style.border-color]="stateMeta().color"
(click)="lanternClick.emit()"
[attr.aria-label]="'Open details for ' + (missionState().survivors[0]?.name ?? 'survivor')"
>
@if (avatarSrc() && !imgError()) {
<img
class="avatar-img"
[src]="avatarSrc()!"
[alt]="missionState().survivors[0]?.name ?? ''"
(error)="imgError.set(true)"
/>
<div class="main-row">
<div class="lantern-wrap">
<button
class="lantern"
[style.border-color]="stateMeta().color"
(click)="lanternClick.emit()"
[attr.aria-label]="'Open details for ' + (missionState().survivors[0]?.name ?? 'survivor')"
>
@if (avatarSrc() && !imgError()) {
<img
class="avatar-img"
[src]="avatarSrc()!"
[alt]="missionState().survivors[0]?.name ?? ''"
(error)="imgError.set(true)"
/>
} @else {
<span class="avatar-initials">{{ initials() }}</span>
}
</button>
<span
class="state-badge"
[style.color]="stateMeta().color"
aria-hidden="true"
>{{ stateMeta().label }}</span>
</div>
<div class="ticker">
@if (latestLogLine(); as line) {
<span class="log-line">{{ line }}</span>
} @else {
<span class="avatar-initials">{{ initials() }}</span>
<span class="log-line idle">The fog stirs…</span>
}
</button>
<span
class="state-badge"
[style.color]="stateMeta().color"
aria-hidden="true"
>{{ stateMeta().label }}</span>
</div>
<div class="ticker">
@if (latestLogLine(); as line) {
<span class="log-line">{{ line }}</span>
} @else {
<span class="log-line idle">The fog stirs…</span>
}
</div>
</div>
@if (killerName(); as kn) {
<div class="killer-strip">
@if (killerAvatarSrc() && !killerImgError()) {
<img
class="killer-thumb"
[src]="killerAvatarSrc()!"
[alt]="kn"
(error)="killerImgError.set(true)"
/>
}
<span class="killer-label">trial against {{ kn }}</span>
</div>
}
</div>

View File

@@ -7,6 +7,7 @@ import {
signal,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { killerAvatarUrl } from '@fog-explorer/encounter-library';
import { avatarUrl, survivorInitials } from './survivor-initials';
/**
@@ -49,6 +50,15 @@ export class MinimisedPanelComponent {
protected readonly imgError = signal(false);
protected killerName = computed(() => this.missionState().mission.killerName ?? null);
protected killerAvatarSrc = computed(() => {
const name = this.killerName();
return name ? killerAvatarUrl(name) : null;
});
protected readonly killerImgError = signal(false);
protected latestLogLine = computed(() => {
const log = this.missionState().recentLog;
return log.length ? log[0].logText : null;

View File

@@ -149,15 +149,15 @@
box-sizing: border-box;
background: transparent;
border: 1px solid var(--fog-amber-dim);
color: var(--fog-text-dim);
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text);
font-family: Verdana, Helvetica, Arial, sans-serif;
font-size: 11px;
padding: 5px 8px;
outline: none;
transition: border-color 0.15s;
}
.character-filter::placeholder {
color: var(--fog-text-faint);
color: var(--fog-text-dim);
font-style: italic;
}
.character-filter:focus {
@@ -178,9 +178,9 @@
background: transparent;
border: none;
border-left: 2px solid transparent;
color: var(--fog-text-faint);
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-dim);
font-family: Verdana, Helvetica, Arial, sans-serif;
font-size: 11px;
padding: 3px 8px;
text-align: left;
cursor: pointer;
@@ -207,8 +207,8 @@
}
.character-btn:hover,
.character-btn:focus-visible {
color: var(--fog-text-dim);
border-left-color: var(--fog-amber-dim);
color: var(--fog-text);
border-left-color: var(--fog-amber);
outline: none;
}
.character-btn--random {