인게임 예약 명령과 장수 상태 표시를 바로잡음

This commit is contained in:
2026-09-04 19:16:56 +00:00
parent 5d220de739
commit 0157ff6a6b
31 changed files with 511 additions and 81 deletions
@@ -24,9 +24,9 @@ const crewTypes = computed(() => props.info.groups.flatMap((group) => group.valu
const selectedCrewType = computed(
() => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null
);
const valid = computed(
() => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1
);
// 예약 시점에는 아직 조건을 충족하지 않는 병종도 선택할 수 있어야 한다.
// 실제 실행 가능 여부는 턴 실행 시점의 기술/국가/장수 상태로 다시 판정한다.
const valid = computed(() => Boolean(selectedCrewType.value) && Number.isFinite(amount.value) && amount.value >= 1);
const estimatedGold = computed(() =>
selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0
);
@@ -135,7 +135,7 @@ watch(
type="button"
class="crew-name"
:class="availabilityClass(selectedCrewType)"
:title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'"
:title="selectedCrewType.available ? '현재 실행 가능' : '현재 실행 불가 · 예약 가능'"
>
{{ selectedCrewType.name }}<small>{{ selectedCrewType.available ? '가능' : '불가' }}</small>
</button>
@@ -189,7 +189,7 @@ watch(
:class="{ selected: crewType.id === selectedCrewTypeId }"
role="button"
tabindex="0"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '현재 실행 불가, 예약 가능'}`"
@click="selectCrewType(crewType)"
@keydown.enter="selectCrewType(crewType)"
>
@@ -256,7 +256,7 @@ watch(
</label>
</span>
<span class="crew-action" @click.stop>
<button type="button" :disabled="!crewType.available" @click="submit(crewType)">
<button type="button" @click="submit(crewType)">
{{ commandName }}
</button>
</span>
@@ -59,7 +59,6 @@ const numberText = (value: unknown, grouped = false): string => {
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
@@ -134,7 +133,11 @@ export const formatReservedCommandBrief = (
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
const crewType =
optionLabel(input?.crewTypes ?? [], args.crewType) ??
input?.recruitment?.groups
.flatMap((group) => group.values)
.find((entry) => entry.id === args.crewType)?.name;
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
@@ -146,7 +149,7 @@ export const formatReservedCommandBrief = (
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
if (itemTypeName) return `${wrap(itemTypeName)}${JosaUtil.pick(itemTypeName, '을')} 판매.`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
@@ -53,6 +53,7 @@ export type CommandInputField = {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: string | number | boolean;
constValue?: string | number;
@@ -12,6 +12,7 @@ import {
} from '../command/commandArgumentDraft';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import { getNpcColor } from '../../utils/npcColor';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import type {
CommandInputContext,
CommandInputField,
@@ -309,6 +310,14 @@ const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex?
const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min;
const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max;
const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step;
const textFieldError = (field: CommandInputField): string => {
const value = values[field.key];
if (field.kind !== 'text' || typeof value !== 'string') return '';
if (field.legacyWidthMax !== undefined && getLegacyStringWidth(value.trim()) > field.legacyWidthMax) {
return `${field.label}은 전각 ${Math.floor(field.legacyWidthMax / 2)}자 또는 반각 ${field.legacyWidthMax}자 이하여야 합니다.`;
}
return '';
};
const OPTION_CARD_COMMANDS = new Set([
'che_물자원조',
@@ -334,7 +343,8 @@ const isValid = computed(() =>
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
(field.max === undefined || length <= field.max) &&
!textFieldError(field)
);
}
if (field.kind === 'number') {
@@ -429,8 +439,18 @@ watch(
:value="String(values[field.key] ?? '')"
:minlength="field.min"
:maxlength="field.max"
:aria-invalid="Boolean(textFieldError(field))"
:aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined"
@input="values[field.key] = ($event.target as HTMLInputElement).value"
/>
<small
v-if="field.kind === 'text' && textFieldError(field)"
:id="`command-arg-${field.key}-error`"
class="argument-error"
role="alert"
>
{{ textFieldError(field) }}
</small>
<div v-else-if="field.kind === 'number'" class="number-options">
<input
:id="`command-arg-${field.key}`"
@@ -647,6 +667,12 @@ watch(
align-items: center;
}
.argument-error {
grid-column: 2;
margin: -2px 6px 5px 0;
color: #ff9a9a;
}
.option-detail {
grid-column: 2;
display: flex;
@@ -9,6 +9,7 @@ import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgres
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
import { generalInjuryPresentation } from '../../utils/generalInjury';
interface GeneralStats {
leadership: number;
@@ -175,14 +176,7 @@ const crewTypeIconBackground = computed(() => {
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff0000' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ffa500' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const injuryInfo = computed(() => generalInjuryPresentation(props.general?.injury ?? 0));
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
@@ -55,6 +55,7 @@ const destination = computed<MessageTarget>(
);
const invalid = computed(() => props.message.option?.invalid === true);
const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
@@ -134,7 +135,12 @@ onBeforeUnmount(() => {
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:class="[
'msg-plate',
`msg-plate-${message.msgType}`,
`msg-plate-${nationDirection}`,
{ 'msg-plate-permission-redacted': permissionRedacted },
]"
:data-id="message.id"
>
<div class="msg-icon">
@@ -262,7 +268,13 @@ onBeforeUnmount(() => {
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
<div
:class="[
'msg-content',
invalid ? 'msg-invalid' : permissionRedacted ? 'msg-permission-redacted' : 'msg-valid',
]"
>
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
@@ -411,6 +423,30 @@ button.msg-target {
color: rgba(255, 255, 255, 0.5);
}
.msg-plate-permission-redacted {
outline: 1px dashed #d7b86c;
background: #3b3427;
}
.msg-plate-permission-redacted .general-icon {
filter: grayscale(1);
opacity: 0.55;
}
.msg-permission-redacted {
color: rgba(255, 244, 214, 0.72);
font-style: italic;
}
.permission-redacted-label {
display: inline-block;
margin-right: 6px;
border: 1px solid #d7b86c;
padding: 1px 4px;
color: #ffe0a0;
font-style: normal;
}
.message-response {
display: flex;
justify-content: flex-end;