feat: add user icon library and in-game selection

This commit is contained in:
2026-08-01 03:40:36 +00:00
parent 395b60cdbe
commit a275e6a234
26 changed files with 1214 additions and 69 deletions
+33
View File
@@ -43,6 +43,7 @@ const error = ref<string | null>(null);
const submitting = ref(false);
const joinConfig = ref<JoinConfig | null>(null);
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
const activeTab = ref<'create' | 'possess'>('create');
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
@@ -54,6 +55,7 @@ const form = ref<JoinForm>({
intel: 0,
character: 'Random',
pic: true,
iconId: undefined,
inheritBonusStat: [0, 0, 0],
});
@@ -401,6 +403,7 @@ const loadConfig = async () => {
form.value = pending.input;
} else {
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
applyBalancedStats();
}
} catch (err) {
@@ -644,6 +647,23 @@ onUnmounted(() => {
</label>
</div>
<div v-if="accountIcons.length" class="icon-choice">
<div class="bonus-title">전용 아이콘 선택</div>
<label class="icon-option"> <input v-model="form.pic" type="checkbox" /> 전용 아이콘 사용 </label>
<div v-if="form.pic" class="icon-list" role="radiogroup" aria-label="전용 아이콘 선택">
<label v-for="icon in accountIcons" :key="icon.id" class="icon-card">
<input v-model="form.iconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl({ picture: icon.picture, imageServer: icon.imageServer })"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
</div>
<div class="stat-actions">
<button @click="applyRandomStats">랜덤형</button>
<button @click="applyFocusedStats('leadership')">통솔형</button>
@@ -1381,4 +1401,17 @@ onUnmounted(() => {
.ghost {
background: transparent;
}
.icon-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.icon-card {
display: flex;
align-items: center;
gap: 3px;
}
</style>
@@ -40,6 +40,7 @@ const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const selectedIconId = ref('');
const cssSaving = ref(false);
const session = useSessionStore();
let cssTimer: number | null = null;
@@ -132,6 +133,7 @@ const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | nu
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
@@ -205,6 +207,10 @@ const loadPage = async (resetImmediateActionIds = true) => {
dieOnPrestartStatus.value = prestartStatus;
if (general) {
Object.assign(form, general.settings);
selectedIconId.value =
iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ??
iconChoices.value[0]?.id ??
'';
}
await Promise.all(logTypes.map((type) => loadLog(type)));
if (resetImmediateActionIds) {
@@ -217,6 +223,20 @@ const loadPage = async (resetImmediateActionIds = true) => {
}
};
const changeGeneralIcon = async () => {
if (!selectedIconId.value) return;
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
try {
await trpc.general.adjustIcon.mutate({
iconId: selectedIconId.value,
clientRequestId: crypto.randomUUID(),
});
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const saveSettings = async () => {
if (!canSave.value) return;
try {
@@ -427,6 +447,25 @@ onMounted(() => {
휴가 신청
</button>
</div>
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
전용 아이콘 변경 (24시간에 1회)<br />
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
<button class="action-button" type="button" @click="changeGeneralIcon">아이콘 변경</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
@@ -759,6 +798,18 @@ dt {
min-height: 32px;
margin-top: 8px;
}
.general-icon-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 6px 0;
}
.general-icon-choice {
display: flex;
align-items: center;
gap: 2px;
}
@media (max-width: 991px) {
.legacy-page {
width: 500px;
@@ -15,6 +15,7 @@ type PendingSelectionAction = {
operation: 'create' | 'reselect';
uniqueName: string;
personality?: string;
iconId?: string;
clientRequestId: string;
};
@@ -26,6 +27,7 @@ const reservation = ref<Reservation | null>(null);
const selectedUniqueName = ref<string | null>(null);
const nations = ref<Nation[]>([]);
const personality = ref('Random');
const selectedIconId = ref('');
const loading = ref(true);
const submitting = ref(false);
const error = ref('');
@@ -66,7 +68,8 @@ const readPendingAction = (): PendingSelectionAction | null => {
if (
(value.operation !== 'create' && value.operation !== 'reselect') ||
typeof value.uniqueName !== 'string' ||
typeof value.clientRequestId !== 'string'
typeof value.clientRequestId !== 'string' ||
(value.iconId !== undefined && typeof value.iconId !== 'string')
) {
return null;
}
@@ -79,13 +82,15 @@ const readPendingAction = (): PendingSelectionAction | null => {
const getPendingAction = (
operation: PendingSelectionAction['operation'],
uniqueName: string,
requestedPersonality?: string
requestedPersonality?: string,
requestedIconId?: string
): PendingSelectionAction => {
const current = readPendingAction();
if (
current?.operation === operation &&
current.uniqueName === uniqueName &&
current.personality === requestedPersonality
current.personality === requestedPersonality &&
current.iconId === requestedIconId
) {
return current;
}
@@ -93,6 +98,7 @@ const getPendingAction = (
operation,
uniqueName,
...(requestedPersonality ? { personality: requestedPersonality } : {}),
...(requestedIconId ? { iconId: requestedIconId } : {}),
clientRequestId: crypto.randomUUID(),
};
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
@@ -200,11 +206,12 @@ const createGeneral = async (): Promise<void> => {
return;
}
submitting.value = true;
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
const pending = getPendingAction('create', candidate.uniqueName, personality.value, selectedIconId.value);
try {
await trpc.join.selectPoolGeneral.mutate({
uniqueName: candidate.uniqueName,
personality: personality.value,
...(selectedIconId.value ? { iconId: selectedIconId.value } : {}),
clientRequestId: pending.clientRequestId,
});
clearPendingAction(pending);
@@ -432,6 +439,24 @@ onBeforeUnmount(() => {
</span>
</td>
</tr>
<tr v-if="config?.user.icons.length">
<th class="legacy-bg1">전콘 선택</th>
<td class="pool-icon-choice">
<label>
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수 전콘
</label>
<label v-for="icon in config.user.icons" :key="icon.id">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt="내 전용 아이콘"
@error="useDefaultGeneralIcon"
/>
</label>
</td>
</tr>
<tr>
<td colspan="2" class="join-guidance">
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.