Compare commits

...

2 Commits

Author SHA1 Message Date
deadvey 5f294cceb2 documentation 2026-05-18 13:49:35 +01:00
deadvey 051bfe46e4 redesigned how file is tokenised into string, keyword, identifier, bracket and character
added support for GOTOs and removed object recursive calls
2026-05-18 13:22:29 +01:00
12 changed files with 924 additions and 774 deletions
+561 -560
View File
File diff suppressed because it is too large Load Diff
+35 -85
View File
@@ -2,6 +2,8 @@
An interactive story telling software. An interactive story telling software.
Read and write people's stories Read and write people's stories
For help with making a story, see [this documentation](/docs/MAKING_A_STORY.md)
For help with syntax, see [this documentation](/docs/SYNTAX.md)
## Install ## Install
@@ -9,11 +11,7 @@ This is not really out of development, but to run it, clone the repo, go into /s
## Technical Details ## Technical Details
### Back-end server -- Rust ### File layout
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:
- animations/ - animations/
- features/ - features/
@@ -30,13 +28,24 @@ File layout:
- scenes/ - scenes/
- images/ - images/
The variables are stored as a hashmap, characters are objects.<br/> ### Back-end server -- Rust
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:
The variables are stored as a hashmap, characters are structs in a hashmap.<br/>
### API ### API
Using the network interface, port 20264.<br/> Using the network interface, port 20264.<br/>
Characters are sent to the frontend and stored there when the character is created on the frontend.<br/> Characters are sent to the frontend and stored there when the character is created on the frontend.<br/>
There are 4 endpoints.
#### /happening
GET Returns what is happening on the next cycle:
``` ```
{ {
@@ -47,35 +56,41 @@ content: String,
character: String, character: String,
choice: [String,String,String,...]
} }
``` ```
#### /character/\<character name\>
GET Returns the struct of the character, ideally this should only be gotten when a new character is made or a character changes a feature.
#### /choice
POST the choice as a JSON formatting number, eg 1 to choose choice index 1, returns "ack" as acknowledgment.
#### /input
POST not yet fully implemented on the syntax side but allows client to send a user input to the server as text. returns "ack" as acknowlegment.
### Frontend -- Python ### Frontend -- Python
Things the frontend can do: Things the frontend should be able to:
- Output text - Output text
- Display the character - Display the character
- Move characters around - Move characters around
- Display a choice to the user that gets sent back to the server - Display a choice to the user that gets sent back to the server
### Syntax ### Files
#### Setup For more info on these files see [Making a Story](/docs/MAKING_A_STORY.md)
This is done in the about.json file, #### story.ha
``` This contains the code for the story, syntax for writing the code can be seen in [the syntax section](/docs/SYNTAX.md)
{
"title": "My Great Story", #### about.json
"description": "please read!" This file contains details about your story such as the title and description.
} #### characters.json
```
#### Characters
See [Character documentation](/docs/CHARACTER.md) for more info See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/> Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
@@ -85,72 +100,6 @@ Move a character with @CHARACTER to fr
> [!NOTE] > [!NOTE]
> fr means front-right for instance. > fr means front-right for instance.
#### Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
```
#### Variables
Variables are referenced with the \$, only integers will be supported.<br/>
#### Selection
Condition based:
```
if (condition) {
}
elif (condition) {
}
else
}
```
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
#### Positioning
```
@CHARACTER to position
PAN to position
```
#### Other
```
label:
GOTO label
```
#### Ending
`END` to exit out of the story
## Implemented stuff ## Implemented stuff
### Commands ### Commands
@@ -160,7 +109,7 @@ GOTO label
| END | Yes | | END | Yes |
| CHOICE/OR/OR | Yes | | CHOICE/OR/OR | Yes |
| IF/ELSE IF/ELSE | No | | IF/ELSE IF/ELSE | No |
| GOTO | No | | GOTO | Yes |
| PAN | No | | PAN | No |
### Character sub-commands ### Character sub-commands
@@ -178,6 +127,7 @@ GOTO label
| ---------- | ----------- | | ---------- | ----------- |
| Variables | No | | Variables | No |
| about.json | No | | about.json | No |
| INPUT | Partially |
## Error codes ## Error codes
+34
View File
@@ -0,0 +1,34 @@
# Making a Story
## Setting up
You need these three files to make a story:
- about.json
- story.ha
- characters.json
## Filling out the files
Inside about.json should be the following content:
```
{
"title": "My Great Story",
"description": "please read!"
}
```
Change the title and description to whatever you would like.<br/>
Inside story.ha should be the code for your story, see [the documentation on coding](/docs/SYNTAX.md) for help with this.<br/>
Finally, inside characters.json should be a JSON formatted object containing all your characters. See [the documentation on characters](/docs/CHARACTERS.md) for help with this file.<br/>
## Making a story file
A story file is just a zip file of these files, so zip up story.ha, characters.json and about.json into a zip file (make sure these are in the root of the zip file).
## Playing your story
To play your story, simply run cargo run \<your zip file\>
+91
View File
@@ -0,0 +1,91 @@
# Syntax
## Setup
This is done in the about.json file,
```
{
"title": "My Great Story",
"description": "please read!"
}
```
## Characters
See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
Customisation is done with @CHARACTER change \<feature\> into \<feature name\><br/>
Move a character with @CHARACTER to fr
> [!NOTE]
> fr means front-right for instance.
## Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
```
## Variables
Variables are referenced with the \$, only integers will be supported.<br/>
## Selection
Condition based:
```
if (condition) {
}
elif (condition) {
}
else
}
```
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
## Positioning
```
@CHARACTER to position
PAN to position
```
## Other
```
label:
GOTO label
```
## Ending
`END` to exit out of the story
+12 -2
View File
@@ -17,7 +17,9 @@ pub struct Character
{ {
name: String, name: String,
gender: String, gender: String,
eye_color: String, eye_color: Colour,
hair_color: Colour,
skin_color: Colour,
pronoun_subject: String, pronoun_subject: String,
pronoun_object: String, pronoun_object: String,
pronoun_deppos: String, pronoun_deppos: String,
@@ -28,7 +30,6 @@ pub struct Character
torso_shape: String, torso_shape: String,
arm_shape: String, arm_shape: String,
leg_shape: String, leg_shape: String,
hair_color: String, // TODO RGB enum
clothing: Clothing, clothing: Clothing,
} }
#[derive(Debug,Deserialize,Serialize,Clone,Default)] #[derive(Debug,Deserialize,Serialize,Clone,Default)]
@@ -39,6 +40,15 @@ pub struct Clothing
bottom: String, bottom: String,
shoes: String, shoes: String,
} }
#[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Colour
{
red: u8,
green: u8,
blue: u8,
}
impl Character { impl Character {
// Big ass ugly match case // Big ass ugly match case
pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), String> { pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), String> {
+5 -5
View File
@@ -51,7 +51,7 @@ async fn main()
error!("No filename specified"); error!("No filename specified");
exit(10); exit(10);
}); });
let file = File::open(format!("../stories/{file_name}")) // Get the file let file = File::open(file_name) // Get the file
.unwrap_or_exit("Failed to read file.", 11); .unwrap_or_exit("Failed to read file.", 11);
let mut archive = ZipArchive::new(file) // Open the archive let mut archive = ZipArchive::new(file) // Open the archive
.unwrap_or_exit("Failed to open archive", 12); .unwrap_or_exit("Failed to open archive", 12);
@@ -93,7 +93,7 @@ async fn main()
// setup the parsing stuff // // setup the parsing stuff //
// Read the file and split it into tokens // Read the file and split it into tokens
// file read from a zip file /story.ha // file read relative to current directory
let mut story_file = archive.by_name("story.ha") let mut story_file = archive.by_name("story.ha")
.unwrap_or_exit("Unable to read story file", 14); .unwrap_or_exit("Unable to read story file", 14);
let mut file_contents = String::new(); let mut file_contents = String::new();
@@ -106,18 +106,18 @@ async fn main()
let space_seperated: Vec<&str> = file_contents let space_seperated: Vec<&str> = file_contents
.split_whitespace() .split_whitespace()
.collect(); .collect();
if ! space_seperated.contains(&"END") if ! space_seperated.contains(&"END") // TODO remove
{ {
warn!("No END statement, story may exit unexpectedly"); warn!("No END statement, story may exit unexpectedly");
} }
let (tokens, labels,_) = tokenise::tokenise(&space_seperated, 0) let (tokens, labels,_) = tokenise::tokenise(&space_seperated, 0)
.unwrap_or_exit("Unable to tokenise data", 15); .unwrap_or_exit("Unable to tokenise data", 15);
debug!("{tokens:?}"); debug!("{tokens:?}\n{labels:?}");
let data_clone2 = Arc::clone(&data_to_send); let data_clone2 = Arc::clone(&data_to_send);
let characters_clone2 = Arc::clone(&characters); let characters_clone2 = Arc::clone(&characters);
// Run the parsing process for the DSL // Run the parsing process for the DSL
info!("DSL parsing begun"); info!("DSL parsing begun");
match parsing::token_parse(&tokens, &characters_clone2, &data_clone2, &rx) match parsing::token_parse(&tokens, &labels, &characters_clone2, &data_clone2, &rx)
{ {
// Exit with error or success // Exit with error or success
Ok(()) => Ok(()) =>
+111 -69
View File
@@ -21,6 +21,7 @@ mod character_parse;
// Returns success or an error string // Returns success or an error string
pub fn token_parse( pub fn token_parse(
tokens: &[tokenise::Token], tokens: &[tokenise::Token],
labels: &HashMap<String,usize>,
characters: &Arc<Mutex<HashMap::<String, character::Character>>>, characters: &Arc<Mutex<HashMap::<String, character::Character>>>,
data_to_send: &Arc<Mutex<api::DataToSend>>, data_to_send: &Arc<Mutex<api::DataToSend>>,
rx: &Receiver<(bool,usize,String)>, rx: &Receiver<(bool,usize,String)>,
@@ -36,85 +37,126 @@ pub fn token_parse(
// Run an infinite loop // Run an infinite loop
'parse_loop: loop 'parse_loop: loop
{ {
debug!("Reading {index}");
// Get the next token // Get the next token
let token = tokenise::get_string_token(tokens, index)?; let token: String = match tokens.get(index)
{
Some(tokenise::Token::Keyword(s)) => s.to_string(),
// Ignore closing braces and jump over opening brace blocks
Some(tokenise::Token::Bracket((bracket,new_index))) =>
{
if bracket == &tokenise::Bracket::Closing
{
index += 1;
}
else
{
warn!("Unexpected brace block, jumping over...");
index = new_index + 1;
}
continue 'parse_loop
},
// Handle a character
Some(tokenise::Token::Character(character_name)) => // TODO add support for narrator
{
index = match character_parse::character_parse(index+1,tokens,character_name.to_string(),characters,data_to_send)
{
Ok(increment) => increment,
Err((err,increment)) =>
{
warn!("{err}");
increment
},
};
continue 'parse_loop
}
Some(_) =>
{
warn!("Unexpected token");
index += 1;
continue 'parse_loop
},
None => return Err("File unexpectedly reached termination point".to_string()),
};
debug!("{index}: {token}"); debug!("{index}: {token}");
// The instructions are related to characters // The instructions are related to characters
if token.starts_with('@') match token.to_lowercase().as_str()
{ {
let character_name: String = token.chars().skip(1).collect(); "end" =>
debug!("Doing something with a character: {character_name}");
// The index is incremented to after the character's instructions
index = match character_parse::character_parse(index+1, tokens, character_name, characters, data_to_send)
{ {
Ok(increment) => increment, info!("END command, exiting");
Err((err,increment)) => return Ok(()) // quit successfully
{ },
warn!("{err}"); "choice" =>
increment
},
};
}
// Miscelleneous instructions
else
{
match token.to_lowercase().as_str()
{ {
"end" => let mut next_token: String = token;
let mut choices: Vec<String> = Vec::new();
let mut choice_indeces: Vec<usize> = Vec::new();
while next_token == "or" || next_token == "choice"
{ {
info!("END command, exiting"); index += 1;
return Ok(()) // quit successfully choices.push
}, (
"choice" => tokenise::get_string_token(tokens, index)?
{ );
debug!("Doing CHOICE"); index += 1;
let mut next_token = token; choice_indeces.push(index+1);
let mut choices: Vec<String> = Vec::new(); index = match tokenise::get_closing_index(tokens,index)
let mut choice_indeces: Vec<usize> = Vec::new();
while next_token == "or" || next_token == "choice"
{ {
index += 1; Ok(new_index) => new_index + 1,
choices.push Err(_) => break,
(
tokenise::get_string_token(tokens, index)?
);
choice_indeces.push(index+1);
index += 2;
next_token = match tokenise::get_string_token(tokens, index)
{
Ok(string) => string,
Err(_) => break,
}
}
debug!("{choices:?}");
debug!("{choice_indeces:?}");
api::modify_data(data_to_send, "choice".to_string(), String::new(), String::new(), choices);
if rx.recv().is_err() { warn!("Error sending choices to client"); }
let choice = match rx.recv()
{
Ok((_,choice,_)) => choice_indeces[choice],
Err(err) =>
{
warn!("Error receiving choice from client, defaulting to choice 0 {err}");
0
}
}; };
let object = tokenise::get_object_token(tokens, choice)?; // TODO make more efficient next_token = match tokenise::get_keyword_token(tokens, index)
debug!("{object:?}"); {
// Don't propogate exit error Ok(string) => string,
let _ = token_parse(object, characters, data_to_send, rx); Err(_) => break,
continue 'parse_loop }
},
"or" =>
{
info!("OR command, jumping over");
index += 2;
continue
},
_ =>
{
warn!("Invalid command: {token}");
} }
debug!("{choices:?}");
debug!("{choice_indeces:?}");
api::modify_data(data_to_send, "choice".to_string(), String::new(), String::new(), choices);
if rx.recv().is_err() { warn!("Error sending choices to client"); }
let choice = match rx.recv()
{
Ok((_,choice,_)) => choice_indeces[choice],
Err(err) =>
{
warn!("Error receiving choice from client, defaulting to choice 0 {err}");
0
}
};
index = choice;
continue 'parse_loop
},
"or" =>
{
info!("OR command, jumping over");
index += 2;
let new_index = tokenise::get_closing_index(tokens, index)?;
index = new_index;
continue 'parse_loop
},
// Jump to a particular index based on a label eg GOTO character_check
"goto" =>
{
index += 1;
let label = tokenise::get_keyword_token(tokens, index)?;
index = match labels.get(&label)
{
Some(label_index) => *label_index,
None =>
{
warn!("Label {label} does not exist");
index
}
};
debug!("Jumping to {index}");
continue 'parse_loop
}
_ =>
{
warn!("Invalid command: {token}");
index += 1;
} }
} }
if rx.recv().is_err() if rx.recv().is_err()
+7 -10
View File
@@ -28,12 +28,9 @@ pub fn character_parse
{ {
let mut sum_index: usize = index; let mut sum_index: usize = index;
// Ensure the index is valid (the index is not beyond the vector) // Ensure the index is valid (the index is not beyond the vector)
let token = match tokens.get(sum_index) { let keyword = tokenise::get_keyword_token(tokens, sum_index)
Some(tokenise::Token::String(s)) => s.clone(), .map_err(|err| (err, sum_index))?;
Some(_) => return Err(("Unexpected token".to_string(), sum_index + 1)), match keyword.to_lowercase().as_str()
None => return Err(("File unexpectedly reached termination point".to_string(), sum_index)),
};
match token.to_lowercase().as_str()
{ {
// The character is saying something, so grab the text and pass it // The character is saying something, so grab the text and pass it
// to the client // to the client
@@ -51,7 +48,7 @@ pub fn character_parse
"change" => "change" =>
{ {
sum_index += 1; sum_index += 1;
let feature = tokenise::get_string_token(tokens, sum_index) let feature = tokenise::get_keyword_token(tokens, sum_index)
.map_err(|err| (err, index))?; .map_err(|err| (err, index))?;
sum_index += 1; sum_index += 1;
let string = tokenise::get_string_token(tokens, sum_index) let string = tokenise::get_string_token(tokens, sum_index)
@@ -69,13 +66,13 @@ pub fn character_parse
"to"|"animate" => "to"|"animate" =>
{ {
sum_index += 1; sum_index += 1;
let content = tokenise::get_string_token(tokens, sum_index) let content = tokenise::get_keyword_token(tokens, sum_index)
.map_err(|err| (err, index))?; .map_err(|err| (err, index))?;
api::modify_data(data_to_send, token.to_lowercase(), content, character_name, vec![]); api::modify_data(data_to_send, keyword.to_lowercase(), content, character_name, vec![]);
}, },
// Catch all condition, if the instruction is unrecognised as a // Catch all condition, if the instruction is unrecognised as a
// character command // character command
_ => return Err((format!("Invalid command: {token}"),sum_index)), _ => return Err((format!("Invalid command: {keyword}"),sum_index)),
} }
sum_index += 1; sum_index += 1;
Ok(sum_index) Ok(sum_index)
+59 -23
View File
@@ -1,13 +1,23 @@
use crate:: use crate::
{ {
exit, exit,
HashMap,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Token pub enum Token
{ {
String(String), String(String),
Object(Vec<Self>), Keyword(String), // Keywords aren't checked for validity in this stage
Identifier(String),
Bracket((Bracket,usize)), // Stores the index of the matching deliminator
Character(String),
}
#[derive(Debug,Clone,PartialEq)]
pub enum Bracket
{
Opening,
Closing,
} }
pub fn get_string_token(tokens: &[Token], index: usize) pub fn get_string_token(tokens: &[Token], index: usize)
@@ -19,12 +29,20 @@ pub fn get_string_token(tokens: &[Token], index: usize)
None => Err("File unexpectedly reached termination point".to_string()), None => Err("File unexpectedly reached termination point".to_string()),
} }
} }
pub fn get_keyword_token(tokens: &[Token], index: usize)
pub fn get_object_token(tokens: &[Token], index: usize) -> Result<String, String>
-> Result<&Vec<Token>, String>
{ {
match tokens.get(index) { match tokens.get(index) {
Some(Token::Object(v)) => Ok(v), Some(Token::Keyword(s)) => Ok(s.clone()),
Some(_) => Err("Unexpected token".to_string()),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_closing_index(tokens: &[Token], index: usize)
-> Result<usize, String>
{
match tokens.get(index) {
Some(Token::Bracket((_, index))) => Ok(*index), // TODO check for closing
Some(_) => Err("Unexpected token".to_string()), Some(_) => Err("Unexpected token".to_string()),
None => Err("File unexpectedly reached termination point".to_string()), None => Err("File unexpectedly reached termination point".to_string()),
} }
@@ -34,37 +52,55 @@ pub fn get_object_token(tokens: &[Token], index: usize)
// It can contain sub-objects (using recursive calls) // It can contain sub-objects (using recursive calls)
// TODO check for END // TODO check for END
pub fn tokenise(space_seperated: &Vec<&str>, mut index: usize) pub fn tokenise(space_seperated: &Vec<&str>, mut index: usize)
-> Result<(Vec<Token>,Vec<(String,usize)>,usize),String> -> Result<(Vec<Token>,HashMap<String,usize>,usize),String>
{ {
let mut tokenised_data: Vec<Token> = Vec::new(); let mut tokenised_data: Vec<Token> = Vec::new();
let mut labels: Vec<(String,usize)> = Vec::new(); let mut labels: HashMap<String, usize> = HashMap::new();
let mut bracket_stack: Vec<usize> = Vec::new();
while index < space_seperated.len() while index < space_seperated.len()
{ {
let mut item = space_seperated[index].to_string(); let mut item = space_seperated[index].to_string();
let object: Vec<Token>; // Characters
if item.starts_with('"') // TODO support ' if item.starts_with('@')
{ {
(index, item) = match tokenise_string(space_seperated, index) let mut chars = item.chars();
{ chars.next();
Some((index,item)) => (index,item), tokenised_data.push(Token::Character(chars.as_str().to_string()));
None => exit(1), }
}; // Strings
else if item.starts_with('"') // TODO support '
{
let Some((new_index, new_item)) = tokenise_string(space_seperated, index)
else { exit(1) };
index = new_index;
item = new_item;
tokenised_data.push(Token::String(item)); tokenised_data.push(Token::String(item));
} }
// Labels
else if item.ends_with(':')
{
let mut chars = item.chars();
chars.next_back();
let label = chars.as_str();
labels.insert
(
label.to_string(),
tokenised_data.len(),
);
}
// On a new indentation level, do a recursive call
else if item == "{" else if item == "{"
{ {
(object, labels, index) = match tokenise(space_seperated, index+1) // !WARNING! recursive call bracket_stack.push(tokenised_data.len());
{ tokenised_data.push(Token::Bracket((Bracket::Opening,0))); // TODO fix no closing brace edge case
Ok((object,labels,index)) => (object,labels,index),
Err(err) => return Err(err),
};
tokenised_data.push(Token::Object(object));
} }
else if item == "}" else if item == "}"
{ {
return Ok((tokenised_data,labels,index)); let prev_index = bracket_stack.pop().unwrap(); // TODO eh
tokenised_data[prev_index] = Token::Bracket((Bracket::Opening, tokenised_data.len()));
tokenised_data.push(Token::Bracket((Bracket::Closing,prev_index)));
} }
else { tokenised_data.push(Token::String(item)); } else { tokenised_data.push(Token::Keyword(item)); }
index += 1; index += 1;
} }
Ok((tokenised_data, labels, 0)) Ok((tokenised_data, labels, 0))
+3 -3
View File
@@ -2,9 +2,9 @@
"tim": { "tim": {
"name": "Timothy Sharpshooter", "name": "Timothy Sharpshooter",
"gender": "Male", "gender": "Male",
"skin_color": "", "skin_color": [0,0,0],
"eye_color": "", "eye_color": [0,0,0],
"hair_color": "", "hair_color": [0,0,0],
"pronoun_subject": "He", "pronoun_subject": "He",
"pronoun_object": "Him", "pronoun_object": "Him",
"pronoun_deppos": "His", "pronoun_deppos": "His",
+2 -13
View File
@@ -1,25 +1,14 @@
@tim says "hello world, it's a good day" @tim says "hello world, it's a good day"
@tim change name "Timothy Fineshooter" @tim change name "Timothy Fineshooter"
@tim change boop "Timothy Fineshooter" label:
@tim to fr
choice "choice numero uno" { choice "choice numero uno" {
@tim animate wave
@tim says "super sad" @tim says "super sad"
choice "choice numero uno" {
@tim animate wave
@tim says "super sad"
}
or "choice numero duo" {
@tim says "super unsad"
}
or "choice numero tres" {
@tim says "hola mi amigos"
}
} }
or "choice numero duo" { or "choice numero duo" {
@tim says "super unsad" @tim says "super unsad"
} }
or "choice numero tres" { or "choice numero tres" {
@tim says "hola mi amigos" @tim says "hola mi amigos"
goto label
} }
END END
BIN
View File
Binary file not shown.