props.framed, updateImageProperties);
z-index: 1;
width: 100%;
height: 100%;
- background-image: radial-gradient(#cccc 0, #0ff3 0.6px);
+ background-image: radial-gradient(#ccc7 0, #0ff3 0.6px);
background-size: 3px 3px;
overflow: hidden;
}
+.sprite.stealth {
+ opacity: 0.5;
+ filter: drop-shadow(0 0 5px cyan) blur(2px);
+}
+
+@supports (mask-type: luminance) {
+ .sprite.stealth {
+ filter: blur(1px) drop-shadow(0 0 2px cyan);
+ opacity: 0.6;
+ }
+ .sprite.stealth .sprite-frame {
+ mask-image: v-bind(url);
+ mask-size: cover;
+ mask-mode: luminance;
+ background-color: #47f;
+ }
+ .sprite.stealth .sprite-frame img {
+ mix-blend-mode: luminosity;
+ mask-image: v-bind(url);
+ mask-size: cover;
+ mask-mode: luminance;
+ filter: grayscale(1) brightness(0.5) contrast(5);
+ }
+}
+
.sprite img[src=""] {
opacity: 0;
}
diff --git a/src/components/viewer/StoryTeller.vue b/src/components/viewer/StoryTeller.vue
index e5e921e..2fedead 100644
--- a/src/components/viewer/StoryTeller.vue
+++ b/src/components/viewer/StoryTeller.vue
@@ -87,7 +87,17 @@ function updateLine(line: string, tags: Record) {
narratorColor.value = tags.color ?? '';
if (tags.sprites !== undefined) {
sprites.value = tags.sprites.split('|').map(toText)
- .map((s) => (s === '' ? null : story.getImage(s)))
+ .map((s) => {
+ const [name, n, effects] = s.split('/');
+ const image = s === '' ? null : story.getImage(`${name}/${n}`);
+ if (effects === '') {
+ return image;
+ }
+ return {
+ ...image,
+ effects: effects.split(','),
+ };
+ })
.filter((s) => s) as SpriteImage[];
}
if (tags.remote !== undefined) {
diff --git a/src/story/interpreter.ts b/src/story/interpreter.ts
index 3d35bbe..6558245 100644
--- a/src/story/interpreter.ts
+++ b/src/story/interpreter.ts
@@ -21,6 +21,7 @@ export type Tags = {
export interface SpriteImage extends CharacterSprite {
image: HTMLImageElement;
+ effects?: string[];
}
function fetchSpriteImage(character: string, s: CharacterSprite) {
diff --git a/unpack/gf-data-ch b/unpack/gf-data-ch
index 76a594d..2467a74 160000
--- a/unpack/gf-data-ch
+++ b/unpack/gf-data-ch
@@ -1 +1 @@
-Subproject commit 76a594d6b9021d0d1cc0a75d84c8f86ae6cf1df7
+Subproject commit 2467a74ea1b8fb189b697853f79875eaffddb685
diff --git a/unpack/src/gfunpack/manual_chapters.py b/unpack/src/gfunpack/manual_chapters.py
index d27c5fa..170f4e2 100644
--- a/unpack/src/gfunpack/manual_chapters.py
+++ b/unpack/src/gfunpack/manual_chapters.py
@@ -193,6 +193,8 @@ def _extra_stories_gunslinger():
('-61', 'C.E. 2023 思域迷航', '2023', []),
('-62', 'C.E. 2023 许可!二次加载', '2023', []),
+ ('-70', '零电荷', '', []),
+
('-8', '猎兔行动', '《苍翼默示录》x《罪恶装备》联动内容', []),
('-14,-15', '独法师', '《崩坏学园2》联动内容', []),
('-19,-20,-22', '荣耀日', '《DJMAX RESPECT》联动内容', []),
diff --git a/unpack/src/gfunpack/stories.py b/unpack/src/gfunpack/stories.py
index 1629144..ccc4e35 100644
--- a/unpack/src/gfunpack/stories.py
+++ b/unpack/src/gfunpack/stories.py
@@ -74,6 +74,9 @@
},
}
+_sprite_effects = {
+ '隐身': 'stealth',
+}
class StoryResources:
audio: dict[str, str]
@@ -173,7 +176,12 @@ def _parse_narrators(self, narrators: str):
sprites.append(('', 0, {}))
else:
attrs = self._parse_effects(narrator)
- sprites.append((sprite.group(1), int(sprite.group(2)), attrs))
+ name = sprite.group(1)
+ if '#' in name:
+ name, effect = name.split('#')
+ assert effect in _sprite_effects, f'unknown sprite effect {effect}'
+ attrs[_sprite_effects[effect]] = ''
+ sprites.append((name, int(sprite.group(2)), attrs))
return sprites, speakers[-1] if len(speakers) > 0 else ''
def _parse_effects(self, effects: str):
@@ -318,7 +326,8 @@ def _process_sprites(self, narrator_string: str):
if character not in self._sprites:
self._sprites[character] = {}
self._sprites[character][sprite] = ''
- sprite_string = '|'.join(f'{character}/{sprite}' for character, sprite, _ in sprites)
+ sprite_string = '|'.join(f'{character}/{sprite}/{",".join(effects.keys())}'
+ for character, sprite, effects in sprites)
self._remote_narrators = set(
character for character, _, attrs in sprites
if '通讯框' in attrs or character in self._remote_narrators