chiark / gitweb /
dice: Initial implementation of piece
authorIan Jackson <ijackson@chiark.greenend.org.uk>
Tue, 12 Apr 2022 21:42:46 +0000 (22:42 +0100)
committerIan Jackson <ijackson@chiark.greenend.org.uk>
Fri, 15 Apr 2022 21:42:35 +0000 (22:42 +0100)
Does not work properly yet.

Signed-off-by: Ian Jackson <ijackson@chiark.greenend.org.uk>
base/html.rs
src/clock.rs
src/dice.rs [new file with mode: 0644]
src/lib.rs
src/pcrender.rs
src/prelude.rs
src/spec.rs

index c26c430e5adf7a93e965c7fcb729a21478885f1a..146bd106a980dc5d2b80659a9d29c9aa0fd9708b 100644 (file)
@@ -171,8 +171,8 @@ impl<'e> HtmlFormat<'e> for String {
   }
 }
 
-hformat_as_display!{ usize u32 u64
-                     isize i32 i64
+hformat_as_display!{ usize u8 u16 u32 u64
+                     isize i8 i16 i32 i64
                      f32 f64 }
 
 #[macro_export]
index 228c3c774a7e80845b1bea351f45cd92c3be6f62..e045af093c18bd2dd120a3694d4e79415f84c602 100644 (file)
@@ -11,7 +11,7 @@ use nix::sys::time::TimeValLike as TVL;
 
 const N: usize = 2;
 
-type Time = i32;
+type Time = i32; // make humantime serde
 
 // ==================== state ====================
 
diff --git a/src/dice.rs b/src/dice.rs
new file mode 100644 (file)
index 0000000..02b3378
--- /dev/null
@@ -0,0 +1,329 @@
+// Copyright 2020-2021 Ian Jackson and contributors to Otter
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// There is NO WARRANTY.
+
+//! Dice
+//!
+//! A "Die" piece
+//!  - has image(s), which is another piece which it displays
+//!  - can display a text string on top of that shape, per face
+//!  - manages flipping
+//!  - has a roll function to select a random face
+//!  - displays and manages a countdown timer
+
+use crate::prelude::*;
+
+const MAX_COOLDOWN: Duration = Duration::from_secs(100);
+const DEFAULT_COOLDOWN: Duration = Duration::from_millis(4000);
+fn default_cooldown() -> Duration { DEFAULT_COOLDOWN }
+
+#[derive(Debug,Serialize,Deserialize)]
+pub struct Spec {
+  // must be >1 faces on image, or >1 texts, and if both, same number
+  image: Box<dyn PieceSpec>,
+  #[serde(default)] labels: SpecLabels,
+  #[serde(default)] occult_label: String,
+  // 1.0 means base circle size on most distant corner of bounding box
+  // minimum is 0.5; maximum is 1.5
+  circle_scale: f64,
+  #[serde(default="default_cooldown")]
+  #[serde(with="humantime_serde")] cooldown: Duration,
+  itemname: Option<String>,
+}
+
+#[derive(Debug,Clone,Serialize,Deserialize)]
+#[serde(untagged)]
+pub enum SpecLabels {
+  Texts(IndexVec<FaceId, String>),
+  OneTo(u8),
+  RangeInclusive([u8; 2]),
+}
+use SpecLabels as SL;
+impl Default for SpecLabels {
+  fn default() -> Self { Self::Texts(default()) }
+}
+
+#[derive(Debug,Serialize,Deserialize)]
+struct Die {
+  /// When occulted, the number of faces of the un-occulted version,
+  ///
+  /// Even though when occulted we only ever show one face, face 0.
+  nfaces: RawFaceId,
+  itemname: String,
+  labels: IndexVec<FaceId, String>, // if .len()==1, always use [0]
+  image: Arc<dyn InertPieceTrait>, // if image.nfaces()==1, always use face 0
+  radius: f64,
+  cooldown_time: Duration,
+}
+
+#[derive(Debug,Serialize,Deserialize)]
+struct State {
+  cooldown_expires: Option<FutureInstant>,
+}
+
+#[typetag::serde(name="Die")]
+impl PieceXData for State {
+  fn dummy() -> Self { State { cooldown_expires: None } }
+}
+
+#[derive(Serialize, Debug)]
+struct CooldownTemplateContext<'c> {
+  radius: f64,
+  remprop: f64,
+  path_d: &'c str,
+  cd_elid: &'c str,
+  total_ms: f64,
+}
+
+#[typetag::serde(name="Die")]
+impl PieceSpec for Spec {
+  #[throws(SpecError)]
+  fn load(&self, _: usize, gpc: &mut GPiece, ig: &Instance, depth: SpecDepth)
+          -> PieceSpecLoaded {
+    gpc.moveable = PieceMoveable::IfWresting;
+    gpc.rotateable = false;
+
+    let SpecLoaded { p: image, occultable: img_occultable } =
+      self.image.load_inert(ig, depth)?;
+
+    let mut nfaces: Option<(RawFaceId, &'static str)> = None;
+    let mut set_nfaces = |n, why: &'static str| {
+      if let Some((already, already_why)) = nfaces {
+        if already != n {
+          throw!(SpecError::WrongNumberOfFaces {
+            got: n,
+            got_why: why.into(),
+            exp: already,
+            exp_why: already_why.into(),
+          })
+        }
+      }
+      nfaces = Some((n, why));
+      Ok::<_,SpecError>(())
+    };
+
+    match image.nfaces() {
+      0 => throw!(SpecError::ZeroFaces),
+      1 => { },
+      n => set_nfaces(n, "image")?,
+    }
+
+    let range_labels = |a: RawFaceId, b: RawFaceId| {
+      if a >= b { throw!(SpecError::InvalidRange(a.into(), b.into())) }
+      let l = (a..=b).map(|i| i.to_string()).collect();
+      Ok::<IndexVec<FaceId,String>,SpecError>(l)
+    };
+
+    let labels = match self.labels {
+      SL::Texts(ref l) => l.clone(),
+      SL::OneTo(n) => range_labels(1,n)?,
+      SL::RangeInclusive([a,b]) => range_labels(a,b)?,
+    };
+
+    let labels = if labels.len() > 0 {
+      let n = labels.len();
+      let n = n.try_into().map_err(|_| SpecError::FarTooManyFaces(n))?;
+      set_nfaces(n, "labels")?;
+      labels.into()
+    } else {
+      index_vec!["".into()]
+    };
+
+    if_let!{ Some((nfaces,_)) = nfaces;
+             else throw!(SpecError::MultipleFacesRequired) };
+
+    let radius = if (0.5 .. 1.5).contains(&self.circle_scale) {
+      image.bbox_approx()?.size()?.len()? * self.circle_scale
+    } else {
+      throw!(SpecError::InvalidSizeScale)
+    };
+
+    let cooldown_time = {
+      let t = self.cooldown;
+      if t <= MAX_COOLDOWN { t }
+      else { throw!(SpecError::TimeoutTooLarge { got: t, max: MAX_COOLDOWN }) }
+    };
+
+    let itemname = self.itemname.clone().unwrap_or_else(
+      || format!("die.{}.{}", nfaces, image.itemname()));
+
+    let _state: &mut State = gpc.xdata_mut(|| State::dummy())?;
+
+    let occultable = match (img_occultable, &self.occult_label) {
+      (None, l) => if l == "" {
+        None
+      } else {
+        throw!(SpecError::UnusedOccultLabel)
+      },
+      (Some((image_occ_ilk, image_occ_image)), occ_label) => {
+        let occ_label = if occ_label == "" && labels.iter().any(|l| l != "") {
+          "?"
+        } else {
+          occ_label
+        };
+
+        let our_ilk =
+          // We need to invent an ilk to allow coalescing of similar
+          // objects.  Here "similar" includes dice with the same
+          // occulted image, but possibly different sets of faces
+          // (ie, different labels).
+          //
+          // We also disregard the cooldown timer parameters, so
+          // similar-looking dice with different cooldowns can be
+          // mixed.  Such things are pathological anyway.
+          //
+          // But we don't want to get mixed up with some other things
+          // that aren't dice.  That would be mad even if they look a
+          // bit like us.
+          format!("die.{}.{}", nfaces, &image_occ_ilk);
+        let our_ilk = GoodItemName::try_from(our_ilk)
+          .map_err(|e| internal_error_bydebug(&e))?
+          .into();
+
+        let our_occ_image = Arc::new(Die {
+          nfaces, cooldown_time, radius,
+          itemname: itemname.clone(),
+          image: image_occ_image,
+          labels: index_vec![occ_label.into()],
+        }) as _;
+
+        Some((our_ilk, our_occ_image))
+      },
+    };
+
+    let die = Die {
+      nfaces, cooldown_time, radius,
+      itemname, labels,
+      image: image.into()
+    };
+
+    PieceSpecLoaded {
+      p: Box::new(die) as _,
+      occultable,
+    }
+  }
+}
+
+macro_rules! def_cooldown_remprop { { $name:ident $($mut:tt)? } => {
+  #[allow(dead_code)]
+  #[throws(IE)]
+  pub fn $name(&self, state: &$($mut)? State) -> f64 {
+    let expires = &$($mut)? state.cooldown_expires;
+    if_let!{ Some(FutureInstant(then)) = *expires; else return Ok(0.) };
+    let now = Instant::now();
+    if now > then {
+      {$(
+        #[allow(unused_mut)] let $mut _x = (); // repetition count
+        *expires = None;
+      )?}
+      return 0.
+    }
+    let remaining = then - now;
+    if remaining > self.cooldown_time {
+      throw!(internal_logic_error(format!(
+        "die {:?}: cooldown time remaining {:?} > total {:?}!, resetting",
+        &self.itemname, 
+        remaining, self.cooldown_time
+      )))
+    }
+    remaining.as_secs_f64() / self.cooldown_time.as_secs_f64()
+  }
+} }
+
+impl Die {
+  def_cooldown_remprop!{ cooldown_remprop         }
+  def_cooldown_remprop!{ cooldown_remprop_mut mut }
+}
+
+#[dyn_upcast]
+impl OutlineTrait for Die {
+  delegate! {
+    to self.image {
+      fn outline_path(&self, scale: f64) -> Result<Html, IE>;
+      fn thresh_dragraise(&self) -> Result<Option<Coord>, IE>;
+      fn bbox_approx(&self) -> Result<Rect, IE>;
+    }
+  }
+}
+
+#[dyn_upcast]
+impl PieceBaseTrait for Die {
+  fn nfaces(&self) -> RawFaceId { self.nfaces }
+
+  fn itemname(&self) -> &str { &self.itemname }
+
+  #[throws(IE)]
+  fn special(&self) -> Option<SpecialClientRendering> {
+    Some(SpecialClientRendering::DieCooldown)
+  }
+}
+
+#[typetag::serde(name="Die")]
+impl PieceTrait for Die {
+  #[throws(IE)]
+  fn describe_html(&self, gpc: &GPiece, _: &GameOccults) -> Html {
+    let nfaces = self.nfaces();
+    let label = &self.labels[gpc.face];
+    let idesc = || self.image.describe_html(gpc.face);
+    let ldesc = || Html::from_txt(label);
+    if label == "" {
+      hformat!("d{} (now showing {})", nfaces, idesc()?)
+    } else if self.labels.iter().filter(|&l| l == label).count() == 1 {
+      hformat!("d{} (now showing {})", nfaces, ldesc())
+    } else {
+      hformat!("d{} (now showing {}, {})", nfaces, idesc()?, ldesc())
+    }
+  }
+
+  #[throws(IE)]
+  fn svg_piece(&self, f: &mut Html, gpc: &GPiece, _: &GameState,
+               vpid: VisiblePieceId) {
+    self.svg(f, vpid, gpc.face, &gpc.xdata)?
+  }
+}
+
+#[typetag::serde(name="Die")]
+impl InertPieceTrait for Die {
+  #[throws(IE)]
+  fn svg(&self, f: &mut Html, vpid: VisiblePieceId, face: FaceId,
+         xdata: &PieceXDataState /* use with care! */) {
+    let state = xdata.get_exp::<State>()?;
+
+    // This is called by PieceTrait::svg_piece, so face may be non-0
+    // despite the promise about face in InertPieceTrait.
+    let face = if self.image.nfaces() == 1 { default() } else { face };
+    self.image.svg(f, vpid, face, xdata)?;
+
+    let remprop = self.cooldown_remprop(state)?;
+    if remprop != 0. {
+
+      let mut path_d = String::new();
+      die_cooldown_path(&mut path_d, self.radius, remprop)?;
+      let cd_elid = format!("def.{}.die.cd", vpid);
+
+      let tc = CooldownTemplateContext {
+        radius: self.radius,
+        path_d: &path_d,
+        cd_elid: &cd_elid,
+        total_ms: self.cooldown_time.as_secs_f64() * 1000.,
+        remprop,
+      };
+
+      write!(f.as_html_string_mut(), "{}",
+             nwtemplates::render("die-cooldown.tera", &tc)?)?;
+    }
+  }
+
+  // add throw operation
+  #[throws(IE)]
+  fn describe_html(&self, face: FaceId) -> Html {
+    let label = &self.labels[face];
+    let idesc = || self.image.describe_html(face);
+    let ldesc = || Html::from_txt(label);
+    if label == "" {
+      hformat!("d{} ({})", self.nfaces, idesc()?)
+    } else {
+      hformat!("d{} ({}, {})", self.nfaces, idesc()?, ldesc())
+    }
+  }
+}
index 0e53f2e708a91a1e311e9368d1f5d3a7a0721f5a..4cc863f43bddcc4f538f508b2ebb9fedc017eb5e 100644 (file)
@@ -30,6 +30,7 @@ pub mod clock;
 pub mod commands;
 pub mod config;
 pub mod deck;
+pub mod dice;
 pub mod debugmutex;
 pub mod debugreader;
 pub mod error;
index e84f816685f8345413ac18122a4ca4328da62e06..bdffa164d57a20145cf4b3efd6f93320ab38a509 100644 (file)
@@ -15,6 +15,7 @@ const DEFKEY_FLIP: UoKey = 'f';
 
 #[derive(Serialize,Debug)]
 pub enum SpecialClientRendering {
+  DieCooldown
 }
 
 #[derive(Debug,Clone)]
index e15ff3dc09c23804e7de3f79ebc25dc5f06aad41..ef2c9a6a45eef973a7197217e5fc217424a88ee7 100644 (file)
@@ -47,6 +47,7 @@ pub use std::str;
 pub use std::str::FromStr;
 pub use std::string::ParseError;
 pub use std::sync::Arc;
+pub use std::sync::atomic::AtomicBool;
 pub use std::sync::mpsc;
 pub use std::thread::{self, sleep};
 pub use std::time::{self, Duration, Instant};
index 77f131ab742316f067e18aa71efcbc4f5fba610f..34aed86c489d2b41a4cdd0d3fa500cb742c7a788 100644 (file)
@@ -12,6 +12,7 @@ use std::collections::hash_set::HashSet;
 use std::fmt::Debug;
 use std::hash::Hash;
 use std::convert::TryFrom;
+use std::time::Duration;
 
 use enum_map::Enum;
 use fehler::{throw,throws};
@@ -21,7 +22,7 @@ use serde::{Deserialize, Serialize};
 use strum::{Display, EnumIter, EnumString};
 use thiserror::Error;
 
-use otter_base::geometry::{Coord,Pos};
+use otter_base::geometry::{Coord,Pos,CoordinateOverflow};
 use otter_base::hformat_as_display;
 
 use crate::accounts::AccountName;
@@ -81,7 +82,19 @@ pub enum SpecError {
   #[error("complex piece where inert needed")] ComplexPieceWhereInertRequired,
   #[error("piece alias not found")]          AliasNotFound,
   #[error("piece alias target is multi spec")] AliasTargetMultiSpec,
+  #[error("invalid range ({0} to {1})")]     InvalidRange(usize,usize),
   #[error("piece alias loop")]               AliasLoop(String),
+  #[error("invalid size scale")]             InvalidSizeScale,
+  #[error("multiple faces required")]        MultipleFacesRequired,
+  #[error("occult label supplied but not occultable")] UnusedOccultLabel,
+  #[error("far too many faces ({0})")]       FarTooManyFaces(usize),
+  #[error("coordinate overflow")]
+  CoordinateOverflow(#[from] CoordinateOverflow),
+  #[error("timeout too large ({got:?} > {max:?})")]
+  TimeoutTooLarge {
+    got: Duration,
+    max: Duration,
+  },
   #[error("wrong number of faces {got_why}={got} != {exp_why}={exp}")]
   WrongNumberOfFaces {
     got: RawFaceId,