use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_fields::is_zero3;
use crate::{ActorId, Mark, NpcId};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CutsceneParty {
#[default]
Present,
Absent,
}
impl CutsceneParty {
pub fn token(self) -> &'static str {
match self {
CutsceneParty::Present => "present",
CutsceneParty::Absent => "absent",
}
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraShot {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub path: Vec<Mark>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seconds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub look_at: Option<Mark>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shot_style: Option<ShotStyle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<CameraSubject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_b: Option<CameraSubject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dist: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub degrees: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bearing: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ShotStyle {
Insert,
LockedOff,
PushIn,
PullBackReveal,
EstablishingCrane,
OrbitArc,
SideTrack,
TwoShot,
LowFollow,
}
impl ShotStyle {
pub fn token(self) -> &'static str {
match self {
ShotStyle::Insert => "insert",
ShotStyle::LockedOff => "locked-off",
ShotStyle::PushIn => "push-in",
ShotStyle::PullBackReveal => "pull-back-reveal",
ShotStyle::EstablishingCrane => "establishing-crane",
ShotStyle::OrbitArc => "orbit-arc",
ShotStyle::SideTrack => "side-track",
ShotStyle::TwoShot => "two-shot",
ShotStyle::LowFollow => "low-follow",
}
}
pub fn default_seconds(self) -> u32 {
match self {
ShotStyle::Insert => 2,
ShotStyle::LockedOff => 6,
ShotStyle::PushIn => 4,
ShotStyle::PullBackReveal => 6,
ShotStyle::EstablishingCrane => 8,
ShotStyle::OrbitArc => 8,
ShotStyle::SideTrack => 8,
ShotStyle::TwoShot => 5,
ShotStyle::LowFollow => 5,
}
}
pub fn needs_moving_subject(self) -> bool {
matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum CameraSubject {
Anchor(Mark),
Npc(NpcSubject),
Actor(ActorSubject),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NpcSubject {
pub npc: NpcId,
#[serde(default, skip_serializing_if = "is_zero3")]
pub offset: [i32; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ActorSubject {
pub actor: ActorId,
#[serde(default, skip_serializing_if = "is_zero3")]
pub offset: [i32; 3],
}
impl CameraSubject {
pub fn offset(&self) -> [i32; 3] {
match self {
CameraSubject::Anchor(s) => s.offset,
CameraSubject::Npc(s) => s.offset,
CameraSubject::Actor(s) => s.offset,
}
}
pub fn canon(&self) -> String {
let (kind, id, o) = match self {
CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
};
format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
}
}
impl std::fmt::Debug for CameraShot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("CameraShot");
d.field("path", &self.path);
match self.seconds {
Some(v) => d.field("seconds", &v),
None => d.field("seconds", &self.seconds),
};
d.field("look_at", &self.look_at);
if self.shot_style.is_some() {
d.field("shot_style", &self.shot_style);
}
if self.subject.is_some() {
d.field("subject", &self.subject);
}
if self.subject_b.is_some() {
d.field("subject_b", &self.subject_b);
}
if self.dist.is_some() {
d.field("dist", &self.dist);
}
if self.degrees.is_some() {
d.field("degrees", &self.degrees);
}
if self.bearing.is_some() {
d.field("bearing", &self.bearing);
}
d.finish()
}
}
impl CameraShot {
pub fn resolved_seconds(&self) -> u32 {
self.seconds
.or(self.shot_style.map(ShotStyle::default_seconds))
.unwrap_or(1)
}
}
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::{QuestEffect, Verb};
use std::collections::BTreeSet;
crate::dw_code! {
pub const CUTSCENE_SHAPE: DwCode = DwCode::new("DW0199", ExitTier::Build);
}
crate::dw_code! {
pub const SHOT_STYLE_INVALID: DwCode = DwCode::new("DW0348", ExitTier::Build);
}
crate::dw_code! {
pub const SHOT_SUBJECT_UNMOVED: DwCode = DwCode::new("DW0349", ExitTier::Build);
}
pub(crate) fn check_cutscene_shape(eff: &QuestEffect, base_path: &str, d: &mut Vec<Diagnostic>) {
let Verb::Cutscene {
shots,
path,
seconds,
..
} = &eff.verb
else {
return;
};
let single = !path.is_empty() || seconds.is_some();
let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
d.push(Diagnostic::error(
CUTSCENE_SHAPE,
"quests",
format!("{base_path}/{field}"),
msg,
));
};
match (!shots.is_empty(), single) {
(true, true) => err(
d,
"shots",
"`cutscene` mixes the multi-shot `shots` list with the single-shot \
`path`/`seconds` fields — use one form: move the single-shot fields into a `shots` \
entry, or drop `shots`"
.to_string(),
),
(false, false) => err(
d,
"shots",
"`cutscene` declares no shot — give a `shots` list of \
`{path, seconds, look_at?}` (multi-shot), or a single-shot `path` + `seconds`"
.to_string(),
),
(false, true) if seconds.is_none() => err(
d,
"seconds",
"single-shot `cutscene` is missing `seconds` — every shot needs a duration".to_string(),
),
_ => {}
}
for (i, shot) in shots.iter().enumerate() {
if shot.shot_style.is_some() {
continue;
}
if shot.path.is_empty() {
err(
d,
&format!("shots/{i}/path"),
"`cutscene` shot has an empty camera `path` — give at least one waypoint (one \
waypoint is a static shot, two or more is a dolly), or use a `shot_style`"
.to_string(),
);
}
if shot.seconds.is_none() {
err(
d,
&format!("shots/{i}/seconds"),
"`cutscene` shot is missing `seconds` — every unstyled shot needs an explicit \
duration (a `shot_style` would supply a default)"
.to_string(),
);
}
}
if shots.is_empty() && single && path.is_empty() {
err(
d,
"path",
"single-shot `cutscene` has an empty camera `path` — give at least one waypoint (one \
waypoint is a static shot, two or more is a dolly)"
.to_string(),
);
}
}
pub(crate) fn cutscene_style_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::{CameraSubject, ShotStyle};
let quests = &c.quests.content;
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let npc_ids = &npc_ids;
let actor_ids: BTreeSet<&str> = quests.actors.iter().map(|a| a.id.as_str()).collect();
fn moves_in(list: &[QuestEffect], scope: &mut Vec<(bool, String)>) {
for e in list {
match &e.verb {
Verb::MoveNpc { npc, .. } => scope.push((false, npc.to_string())),
Verb::MoveActor { actor, .. } => scope.push((true, actor.to_string())),
Verb::Sequence { steps } => {
for st in steps {
moves_in(&st.effects, scope);
}
}
_ => {}
}
}
}
fn subject_check(
sub: &CameraSubject,
field: &str,
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let (unknown, kind, id) = match sub {
CameraSubject::Anchor(_) => return,
CameraSubject::Npc(s) => (!npc_ids.contains(s.npc.as_str()), "npc", s.npc.as_str()),
CameraSubject::Actor(s) => (
!actor_ids.contains(s.actor.as_str()),
"actor",
s.actor.as_str(),
),
};
if unknown {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
format!("{path}/{field}"),
format!(
"shot `{field}` references unknown {kind} `{id}` — declare it (stage 2 npcs / \
stage-5 `actors`) or correct the reference"
),
));
}
}
fn check_shot_list(
eff: &QuestEffect,
scope: &[(bool, String)],
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let Verb::Cutscene { shots, .. } = &eff.verb else {
return;
};
for (i, shot) in shots.iter().enumerate() {
let spath = format!("{path}/shots/{i}");
let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
d.push(Diagnostic::error(
SHOT_STYLE_INVALID,
"quests",
format!("{spath}/{field}"),
msg,
));
};
let Some(style) = shot.shot_style else {
for (field, present) in [
("subject", shot.subject.is_some()),
("subject_b", shot.subject_b.is_some()),
("dist", shot.dist.is_some()),
("degrees", shot.degrees.is_some()),
("bearing", shot.bearing.is_some()),
] {
if present {
err(
d,
field,
format!(
"`{field}` is a `shot_style` parameter but this shot declares no \
`shot_style` — add one, or drop the field"
),
);
}
}
continue;
};
let token = style.token();
match &shot.subject {
None => err(
d,
"subject",
format!(
"`shot_style: {token}` needs a `subject` — the anchor, npc, or actor the \
shot frames"
),
),
Some(sub) => subject_check(sub, "subject", &spath, npc_ids, actor_ids, d),
}
match (&shot.subject_b, style == ShotStyle::TwoShot) {
(None, true) => err(
d,
"subject_b",
"`shot_style: two-shot` frames two subjects — give `subject_b`".to_string(),
),
(Some(_), false) => err(
d,
"subject_b",
format!("`subject_b` is only meaningful on `two-shot`, not `{token}`"),
),
(Some(sub), true) => subject_check(sub, "subject_b", &spath, npc_ids, actor_ids, d),
(None, false) => {}
}
if let Some(g) = shot.degrees {
if style != ShotStyle::OrbitArc {
err(
d,
"degrees",
format!("`degrees` is only meaningful on `orbit-arc`, not `{token}`"),
);
} else if !(45.0..=120.0).contains(&g) {
err(
d,
"degrees",
format!(
"`orbit-arc` sweep `{g}` is outside `45..=120` degrees (the dossier's \
readable-orbit range)"
),
);
}
}
if let Some(dist) = shot.dist
&& !(1.0..=48.0).contains(&dist)
{
err(
d,
"dist",
format!("`dist` `{dist}` is outside the sane `1..=48` block range"),
);
}
if let Some(b) = shot.bearing
&& !(-360.0..=360.0).contains(&b)
{
err(
d,
"bearing",
format!("`bearing` `{b}` is outside `-360..=360` degrees"),
);
}
if style.needs_moving_subject() {
let moved = match &shot.subject {
Some(CameraSubject::Npc(s)) => {
scope.iter().any(|(a, id)| !a && id == s.npc.as_str())
}
Some(CameraSubject::Actor(s)) => {
scope.iter().any(|(a, id)| *a && id == s.actor.as_str())
}
Some(CameraSubject::Anchor(_)) => false,
None => true,
};
if !moved {
d.push(Diagnostic::error(
SHOT_SUBJECT_UNMOVED,
"quests",
format!("{spath}/subject"),
format!(
"`shot_style: {token}` dollies with a MOVING subject, but this \
subject has no matching `move-npc`/`move-actor` in the same effect \
group or sequence — add the move alongside the cutscene, or use a \
static style (`locked-off`, `push-in`)"
),
));
}
}
}
}
fn walk_list(
list: &[QuestEffect],
outer_scope: &[(bool, String)],
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let mut scope = outer_scope.to_vec();
moves_in(list, &mut scope);
for (j, e) in list.iter().enumerate() {
let epath = format!("{path}/{j}");
check_shot_list(e, &scope, &epath, npc_ids, actor_ids, d);
for (pseg, _kseg, inner) in e.nested_effect_lists_labeled() {
let inherited: &[(bool, String)] = if matches!(&e.verb, Verb::Sequence { .. }) {
&scope
} else {
&[]
};
walk_list(
inner,
inherited,
&format!("{epath}/{pseg}"),
npc_ids,
actor_ids,
d,
);
}
}
}
for (i, q) in quests.quests.iter().enumerate() {
for (key, effs) in &q.on_objective_complete {
walk_list(
effs,
&[],
&format!("/content/quests/{i}/on_objective_complete/{key}"),
npc_ids,
&actor_ids,
d,
);
}
walk_list(
&q.on_complete,
&[],
&format!("/content/quests/{i}/on_complete"),
npc_ids,
&actor_ids,
d,
);
}
for (i, t) in quests.triggers.iter().enumerate() {
walk_list(
&t.effects,
&[],
&format!("/content/triggers/{i}/effects"),
npc_ids,
&actor_ids,
d,
);
}
}