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.
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT;
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
BIN
apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K01_charPreview_portrait.webp
Executable file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/overlay/public/avatars/K02_TheWraith_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K04_TheNurse_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K05_TheShape_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K06_TheHag_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K11_ThePig_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K12_TheClown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K14_TheLegion_Portrait.webp
Executable file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/overlay/public/avatars/K15_ThePlague_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp
Executable file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K18_TheOni_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K21_TheBlight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K22_TheTwins_Portrait.webp
Executable file
|
After Width: | Height: | Size: 105 KiB |
BIN
apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K26_TheArtist_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/K28_TheDredge_Portrait.webp
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/K30_TheKnight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K36_TheLich_Portrait.webp
Executable file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp
Executable file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
@@ -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 ── */
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||