summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-09-17 16:57:23 +0200
committermetamuffin <metamuffin@disroot.org>2024-09-17 16:57:34 +0200
commit7cacd111e2b443bac291244b168c20e2c8bf69ec (patch)
tree277ace5a4e8223df4b156e766c2660406101a82b
parent77a73b415888b0285ba64b27c3f69440216e475c (diff)
downloadhurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar
hurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar.bz2
hurrycurry-7cacd111e2b443bac291244b168c20e2c8bf69ec.tar.zst
add hint-based tutorial for item crafting
-rw-r--r--server/protocol/src/lib.rs2
-rw-r--r--server/src/commands.rs30
-rw-r--r--server/src/entity/bot.rs5
-rw-r--r--server/src/entity/customers.rs2
-rw-r--r--server/src/entity/mod.rs3
-rw-r--r--server/src/entity/tutorial.rs218
-rw-r--r--server/src/server.rs2
-rw-r--r--test-client/main.ts17
-rw-r--r--test-client/protocol.ts4
-rw-r--r--test-client/visual.ts21
10 files changed, 282 insertions, 22 deletions
diff --git a/server/protocol/src/lib.rs b/server/protocol/src/lib.rs
index 54a7f868..706747f4 100644
--- a/server/protocol/src/lib.rs
+++ b/server/protocol/src/lib.rs
@@ -136,7 +136,7 @@ pub enum PacketS {
},
}
-#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
+#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Message {
Text(String),
diff --git a/server/src/commands.rs b/server/src/commands.rs
index 96d9ab75..904305bf 100644
--- a/server/src/commands.rs
+++ b/server/src/commands.rs
@@ -15,7 +15,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-use crate::{entity::bot::BotDriver, server::Server};
+use crate::{
+ entity::{bot::BotDriver, tutorial::Tutorial},
+ server::Server,
+};
use anyhow::{anyhow, bail, Result};
use clap::{Parser, ValueEnum};
use hurrycurry_bot::algos::ALGO_CONSTRUCTORS;
@@ -58,18 +61,28 @@ enum Command {
/// List all recipes and maps
List,
/// Send an effect
- Effect { name: String },
+ Effect {
+ name: String,
+ },
/// Send an item
- Item { name: String },
+ Item {
+ name: String,
+ },
/// Reload the resource index
ReloadIndex,
#[clap(alias = "summon-bot", alias = "spawn-bot")]
- CreateBot { algo: String, name: Option<String> },
+ CreateBot {
+ algo: String,
+ name: Option<String>,
+ },
/// Reload the current map
#[clap(alias = "r")]
Reload,
/// Shows the recipe book
Book,
+ StartTutorial {
+ item: String,
+ },
}
#[derive(ValueEnum, Clone)]
@@ -217,6 +230,15 @@ impl Server {
info.players
)
}
+ Command::StartTutorial { item } => {
+ let item = self
+ .game
+ .data
+ .get_item_by_name(&item)
+ .ok_or(anyhow!("unknown item"))?;
+ // TODO prevent too many (> 1) tutorials for this player
+ self.entities.push(Box::new(Tutorial::new(player, item)));
+ }
}
Ok(())
}
diff --git a/server/src/entity/bot.rs b/server/src/entity/bot.rs
index 79ca97f1..6f39fab2 100644
--- a/server/src/entity/bot.rs
+++ b/server/src/entity/bot.rs
@@ -29,7 +29,7 @@ pub struct BotDriver<T> {
join_data: Option<(String, i32)>,
id: PlayerID,
interacting: bool,
- pub left: bool,
+ left: bool,
}
impl<T: BotAlgo> BotDriver<T> {
@@ -44,6 +44,9 @@ impl<T: BotAlgo> BotDriver<T> {
}
}
impl<T: BotAlgo> Entity for BotDriver<T> {
+ fn finished(&self) -> bool {
+ self.left
+ }
fn tick(&mut self, c: EntityContext<'_>) -> Result<()> {
if let Some((name, character)) = self.join_data.take() {
self.id = PlayerID(random()); // TODO bad code, can collide
diff --git a/server/src/entity/customers.rs b/server/src/entity/customers.rs
index da905bc0..8c515e92 100644
--- a/server/src/entity/customers.rs
+++ b/server/src/entity/customers.rs
@@ -58,7 +58,7 @@ impl Entity for Customers {
load_map: c.load_map,
})?;
}
- self.customers.retain(|c| !c.left);
+ self.customers.retain(|c| !c.finished());
Ok(())
}
}
diff --git a/server/src/entity/mod.rs b/server/src/entity/mod.rs
index 70c5c785..f87dbb32 100644
--- a/server/src/entity/mod.rs
+++ b/server/src/entity/mod.rs
@@ -59,6 +59,9 @@ pub trait Entity {
fn tick(&mut self, _c: EntityContext<'_>) -> Result<()> {
Ok(())
}
+ fn finished(&self) -> bool {
+ false
+ }
fn destructor(&mut self, _c: EntityContext<'_>) {}
fn interact(
&mut self,
diff --git a/server/src/entity/tutorial.rs b/server/src/entity/tutorial.rs
index 20b1d03e..24c83550 100644
--- a/server/src/entity/tutorial.rs
+++ b/server/src/entity/tutorial.rs
@@ -1,16 +1,68 @@
use super::{Entity, EntityContext};
use anyhow::Result;
-use hurrycurry_protocol::{glam::IVec2, PlayerID};
+use hurrycurry_protocol::{
+ glam::IVec2, ItemIndex, Message, PacketC, PlayerID, Recipe, RecipeIndex, TileIndex,
+};
+use log::{debug, warn};
pub struct Tutorial {
player: PlayerID,
+ target: ItemIndex,
+
+ current_hint: Option<(Option<IVec2>, Message)>,
+ delete_timer: f32,
+}
+
+impl Tutorial {
+ pub fn new(player: PlayerID, item: ItemIndex) -> Self {
+ Self {
+ player,
+ target: item,
+ current_hint: None,
+ delete_timer: 1.5,
+ }
+ }
}
impl Entity for Tutorial {
- fn tick(&mut self, _c: EntityContext<'_>) -> Result<()> {
+ fn finished(&self) -> bool {
+ self.delete_timer <= 0.
+ }
+ fn tick(&mut self, c: EntityContext<'_>) -> Result<()> {
+ let mut hint = StepContext {
+ ent: &c,
+ player: self.player,
+ recursion_abort: 0,
+ }
+ .aquire_item(self.target)
+ .err();
+ if hint.is_none() {
+ self.delete_timer -= c.dt;
+ if self.delete_timer <= 0. {
+ hint = None
+ } else {
+ hint = Some((None, Message::Text("Tutorial finished.".to_string())));
+ }
+ }
+
+ if hint != self.current_hint {
+ if let Some((position, _)) = self.current_hint.take() {
+ c.packet_out.push_back(PacketC::ServerHint {
+ position,
+ message: None,
+ });
+ }
+ if let Some((position, message)) = hint.clone() {
+ c.packet_out.push_back(PacketC::ServerHint {
+ position,
+ message: Some(message),
+ });
+ }
+ self.current_hint = hint;
+ }
+
Ok(())
}
- fn destructor(&mut self, _c: EntityContext<'_>) {}
fn interact(
&mut self,
_c: EntityContext<'_>,
@@ -20,3 +72,163 @@ impl Entity for Tutorial {
Ok(false)
}
}
+
+struct StepContext<'a> {
+ ent: &'a EntityContext<'a>,
+ recursion_abort: usize,
+ player: PlayerID,
+}
+
+impl<'a> StepContext<'a> {
+ fn is_hand_item(&self, item: ItemIndex) -> bool {
+ self.ent
+ .game
+ .players
+ .get(&self.player)
+ .map_or(false, |p| p.item.as_ref().map_or(false, |i| i.kind == item))
+ }
+ fn find_recipe_with_output(&self, item: ItemIndex) -> Option<RecipeIndex> {
+ self.ent
+ .game
+ .data
+ .recipes
+ .iter()
+ .enumerate()
+ .find(|(_, r)| r.outputs().contains(&item))
+ .map(|(i, _)| RecipeIndex(i))
+ }
+ fn find_item_on_map(&self, item: ItemIndex) -> Option<IVec2> {
+ self.ent
+ .game
+ .tiles
+ .iter()
+ .find(|(_, t)| t.item.as_ref().map_or(false, |t| t.kind == item))
+ .map(|(p, _)| *p)
+ }
+ fn find_tile(&self, tile: TileIndex) -> Option<IVec2> {
+ self.ent
+ .game
+ .tiles
+ .iter()
+ .find(|(_, t)| t.kind == tile)
+ .map(|(p, _)| *p)
+ }
+ fn aquire_placed_item(&mut self, item: ItemIndex) -> Result<IVec2, (Option<IVec2>, Message)> {
+ debug!(
+ "aquire placed item {:?}",
+ self.ent.game.data.item_names[item.0]
+ );
+ if let Some(pos) = self.find_item_on_map(item) {
+ return Ok(pos);
+ }
+ self.aquire_item(item)?;
+ Err((None, Message::Text("put down this item".to_string())))
+ }
+ fn aquire_item(&mut self, item: ItemIndex) -> Result<(), (Option<IVec2>, Message)> {
+ debug!("aquire item {:?}", self.ent.game.data.item_names[item.0]);
+ self.recursion_abort += 1;
+ if self.recursion_abort > 32 {
+ warn!("too much recursion");
+ return Err((
+ None,
+ Message::Text("server cant handle the recipe, too much recursion".to_string()),
+ ));
+ }
+ if self.is_hand_item(item) {
+ return Ok(());
+ }
+ if let Some(pos) = self.find_item_on_map(item) {
+ return Err((Some(pos), Message::Text("pickup".to_string())));
+ }
+ if let Some(recipe) = self.find_recipe_with_output(item) {
+ let r = &self.ent.game.data.recipes[recipe.0];
+ match r {
+ Recipe::Instant {
+ tile: Some(tile),
+ inputs: [None, None],
+ ..
+ } => {
+ if let Some(pos) = self.find_tile(*tile) {
+ return Err((Some(pos), Message::Text("take from crate".to_string())));
+ }
+ }
+ Recipe::Instant {
+ tile: None,
+ inputs: [Some(a), Some(b)],
+ ..
+ } => {
+ let apos = self.aquire_placed_item(*a)?;
+ self.aquire_item(*b)?;
+ return Err((Some(apos), Message::Text("interact here".to_string())));
+ }
+ Recipe::Instant {
+ tile: None,
+ inputs: [Some(input), None],
+ ..
+ } => {
+ self.aquire_item(*input)?;
+ return Err((None, Message::Text("interact with empty tile".to_string())));
+ }
+ Recipe::Active {
+ tile: Some(tile),
+ input,
+ speed,
+ ..
+ } => {
+ if let Some(pos) = self.find_tile(*tile) {
+ if let Some(item) = &self.ent.game.tiles.get(&pos).unwrap().item {
+ if item.kind == *input {
+ return Err((
+ Some(pos),
+ Message::Text(format!("hold interact here")),
+ ));
+ } else {
+ return Err((Some(pos), Message::Text(format!("clear tile"))));
+ }
+ }
+ self.aquire_item(*input)?;
+ return Err((
+ Some(pos),
+ Message::Text(format!("active here for {:.01}s", 1. / speed)),
+ ));
+ }
+ }
+ Recipe::Passive {
+ tile: Some(tile),
+ input,
+ ..
+ } => {
+ if let Some(pos) = self.find_tile(*tile) {
+ if let Some(item) = &self.ent.game.tiles.get(&pos).unwrap().item {
+ if item.kind == *input {
+ return Err((Some(pos), Message::Text(format!("wait for finish"))));
+ } else {
+ return Err((Some(pos), Message::Text(format!("clear tile"))));
+ }
+ }
+ self.aquire_item(*input)?;
+ return Err((
+ Some(pos),
+ Message::Text(format!(
+ "put on {}",
+ self.ent.game.data.tile_name(*tile)
+ )),
+ ));
+ }
+ }
+ Recipe::Passive {
+ tile: None, input, ..
+ } => {
+ self.aquire_item(*input)?;
+ return Err((None, Message::Text(format!("wait for finish"))));
+ }
+ _ => warn!("recipe too hard {r:?}"),
+ }
+ }
+ warn!(
+ "stuck at making item {:?}",
+ self.ent.game.data.item_names[item.0]
+ );
+ Err((None, Message::Text(format!("stuck"))))
+ }
+}
diff --git a/server/src/server.rs b/server/src/server.rs
index 01460cb9..2259d159 100644
--- a/server/src/server.rs
+++ b/server/src/server.rs
@@ -670,6 +670,8 @@ impl Server {
warn!("Entity tick failed: {e}")
}
}
+ self.entities.retain(|e| !e.finished());
+
if let Some(map) = load_map {
return Some((map, Some(Duration::from_secs(300))));
}
diff --git a/test-client/main.ts b/test-client/main.ts
index 6ed3c6e1..875e420d 100644
--- a/test-client/main.ts
+++ b/test-client/main.ts
@@ -97,12 +97,13 @@ export interface MessageData {
inner: Message
anim_position: V2,
anim_size: number,
- timeout: MessageTimeout,
+ timeout?: MessageTimeout,
}
export const players = new Map<PlayerID, PlayerData>()
export const tiles = new Map<string, TileData>()
export const items_removed = new Set<ItemData>()
+export const server_hints = new Map<string, MessageData>()
export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [], maps: [] }
@@ -245,6 +246,12 @@ function packet(p: PacketC) {
case "movement_sync":
players.get(my_id)!.position = last_server_sent_position
break;
+ case "server_hint":
+ if (p.message) server_hints.set(p.position + "", { inner: p.message, anim_size: 0., anim_position: p.position ? { x: p.position[0] + 0.5, y: p.position[1] + 0.5 } : players.get(my_id)!.anim_position })
+ else server_hints.delete(p.position + "")
+ break;
+ case "environment":
+ break
case "menu":
switch (p.menu) {
case "book": open("https://s.metamuffin.org/static/hurrycurry/book.pdf"); break
@@ -272,6 +279,8 @@ function keyboard(ev: KeyboardEvent, down: boolean) {
if (down && ev.code == "Numpad3") send({ player: my_id, type: "communicate", message: { text: "/start sophomore" } })
if (down && ev.code == "Numpad4") send({ player: my_id, type: "communicate", message: { text: "/start debug" } })
if (down && ev.code == "Numpad5") send({ player: my_id, type: "communicate", message: { text: "/start bus" } })
+ if (down && ev.code == "Numpad8") send({ player: my_id, type: "communicate", message: { text: "/start-tutorial plate:seared-patty,sliced-bun" } })
+ if (down && ev.code == "Numpad9") send({ player: my_id, type: "communicate", message: { text: "/start-tutorial plate:bun" } })
if (down && ev.code == "Numpad0") send({ player: my_id, type: "communicate", message: { text: "/end" } })
if (down) keys_down.add(ev.code)
else keys_down.delete(ev.code)
@@ -364,6 +373,9 @@ function frame_update(dt: number) {
}
remove.forEach(i => items_removed.delete(i))
+ for (const [_, h] of server_hints)
+ tick_message(h, dt);
+
lerp_exp_v2_mut(camera, p.position, dt * 10.)
if (global_message && tick_message(global_message, dt)) global_message = undefined
@@ -399,7 +411,8 @@ function draw() {
function tick_message(m: MessageData | undefined, dt: number): boolean {
if (!m) return true
- m.anim_size = lerp_exp(m.anim_size, m.timeout.remaining > 0.3 ? 1 : 0, dt * 3)
+ m.anim_size = lerp_exp(m.anim_size, m.timeout ? m.timeout.remaining > 0.3 ? 1 : 0 : 1, dt * 3)
+ if (!m.timeout) return false
m.timeout.remaining -= dt;
return m.timeout.remaining <= 0
}
diff --git a/test-client/protocol.ts b/test-client/protocol.ts
index f88d1dec..d0f5f997 100644
--- a/test-client/protocol.ts
+++ b/test-client/protocol.ts
@@ -1,5 +1,3 @@
-import { V2 } from "./util.ts";
-
/*
Hurry Curry! - a game about cooking
Copyright 2024 metamuffin
@@ -60,7 +58,7 @@ export type PacketC =
| { type: "update_map", tile: Vec2, kind: TileIndex | null, neighbors: [TileIndex | null] } // A map tile was changed
| { type: "communicate", player: PlayerID, message?: Message, timeout?: MessageTimeout } // A player wants to communicate something, message is null when cleared
| { type: "server_message", text: string } // Text message from the server
- | { type: "server_hint", message?: Message, position?: V2 } // Hint message from server with optional position. Message is unset to clear previous message
+ | { type: "server_hint", message?: Message, position?: Vec2 } // Hint message from server with optional position. Message is unset to clear previous message
| { type: "score" } & Score // Supplies information for score OSD
| { type: "menu" } & Menu // Open a menu on the client-side
| { type: "environment", effects: string[] }
diff --git a/test-client/visual.ts b/test-client/visual.ts
index 4c0a8ecc..09b6ec85 100644
--- a/test-client/visual.ts
+++ b/test-client/visual.ts
@@ -15,7 +15,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import { ItemData, MessageData, PlayerData, TileData, camera, camera_scale, canvas, ctx, data, demands_completed, demands_failed, get_interact_target, global_message, interact_active_anim, interact_possible_anim, interact_target_anim, items_removed, keys_down, my_id, nametag_scale_anim, players, points, tiles, time_remaining } from "./main.ts";
+import { ItemData, MessageData, PlayerData, TileData, camera, camera_scale, canvas, ctx, data, demands_completed, demands_failed, get_interact_target, global_message, interact_active_anim, interact_possible_anim, interact_target_anim, items_removed, keys_down, my_id, nametag_scale_anim, players, points, server_hints, tiles, time_remaining } from "./main.ts";
import { PLAYER_SIZE } from "./movement.ts";
import { draw_item_sprite, draw_tile_sprite, ItemName, TileName } from "./tiles.ts";
import { V2, ceil_v2, floor_v2 } from "./util.ts";
@@ -71,9 +71,14 @@ export function draw_ingame() {
if (player.message_persist) draw_message(player.message_persist)
}
+ // Draw nametags
for (const [_, player] of players)
draw_player_nametag(player)
+ // Draw server hints
+ for (const [_, message] of server_hints)
+ draw_message(message)
+
// Draw interact target
draw_interact_target()
@@ -221,12 +226,14 @@ function draw_message(m: MessageData) {
ctx.closePath()
ctx.fill()
- const t = m.timeout.remaining / m.timeout.initial;
- ctx.beginPath()
- ctx.strokeStyle = `hsl(${Math.sqrt(t) * 0.3}turn, 100%, 50%)`
- ctx.lineWidth = 0.1
- ctx.arc(0, -1, 0.45, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (1 - t))
- ctx.stroke()
+ if (m.timeout) {
+ const t = m.timeout.remaining / m.timeout.initial;
+ ctx.beginPath()
+ ctx.strokeStyle = `hsl(${Math.sqrt(t) * 0.3}turn, 100%, 50%)`
+ ctx.lineWidth = 0.1
+ ctx.arc(0, -1, 0.45, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (1 - t))
+ ctx.stroke()
+ }
ctx.translate(0, -1)
draw_item_sprite(ctx, data.item_names[m.inner.item] as ItemName)