Render dynamic weather for ASCII landscapes. Inspired and powered by ~iajrz's climate program.
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 271 lines 6.5 kB view raw
1package main 2 3import ( 4 "bufio" 5 "fmt" 6 "github.com/muesli/termenv" 7 "github.com/ojrac/opensimplex-go" 8 "io" 9 "os" 10 "path" 11 "unicode" 12) 13 14// Style represents a pair of colors, foreground and background. 15type Style struct { 16 fg termenv.Color 17 bg termenv.Color 18} 19 20// Convert is a wrapper for termenv.Profile.Convert, it converts the foreground 21// and background colors of a Style to be within a given terminal's Profile. 22func (s Style) Convert(profile termenv.Profile) Style { 23 return Style{ 24 profile.Convert(s.fg), 25 profile.Convert(s.bg), 26 } 27} 28 29// String fills the Stringer interface and returns an ANSI escape code 30// formatted to produce text in the specified Style. 31func (s Style) String() string { 32 if s.fg == nil { 33 s.fg = termenv.NoColor{} 34 } 35 if s.bg == nil { 36 s.bg = termenv.NoColor{} 37 } 38 // TODO: Looks like the background color is overriding the foreground color. What's the proper way to do this? 39 return fmt.Sprintf("%s%s;%sm", termenv.CSI, s.fg.Sequence(false), s.bg.Sequence(true)) 40} 41 42// Scene holds all the necessary information to make a dynamic, weather-y ASCII 43// landscape. 44type Scene struct { 45 foreground [][]rune 46 background [][]rune 47 windground [][]rune 48 depth [][]rune 49 forecast Forecast 50 51 generator opensimplex.Noise 52 53 Width int 54 Height int 55} 56 57const ( 58 fgPath = "foreground.txt" 59 depthPath = "depth.txt" 60 windPath = "wind.txt" 61 62 earlyPath = "early.txt" 63 morningPath = "morning.txt" 64 afternoonPath = "afternoon.txt" 65 nightPath = "night.txt" 66) 67 68var timeToPath = []string{ 69 earlyPath, 70 morningPath, 71 afternoonPath, 72 nightPath, 73} 74 75// NewScene accepts a path to a folder containing foreground, windground, 76// depth, and background files and loads them from disk as needed to generate 77// imagery for the given forecast. 78func NewScene(scenePath string, forecast Forecast) (Scene, error) { 79 var s Scene 80 var err error 81 82 s.forecast = forecast 83 s.generator = opensimplex.NewNormalized(0) 84 85 s.foreground, err = readRunesFromFile(path.Join(scenePath, fgPath)) 86 if err != nil { 87 return s, err 88 } 89 s.depth, err = readRunesFromFile(path.Join(scenePath, depthPath)) 90 if err != nil { 91 return s, err 92 } 93 94 s.windground, err = readRunesFromFile(path.Join(scenePath, windPath)) 95 if err != nil { 96 return s, err 97 } 98 99 bgPath := timeToPath[forecast.time] 100 s.background, err = readRunesFromFile(path.Join(scenePath, bgPath)) 101 if err != nil { 102 return s, err 103 } 104 105 s.normalize() 106 107 /*m.depthData = make([][]uint8, 0) 108 for i := range depthRunes { 109 m.depthData = append(m.depthData, make([]uint8, 0)) 110 for j := range depthRunes[i] { 111 m.depthData[i] = append(m.depthData[i], uint8(depthRunes[i][j])-48) 112 } 113 }*/ 114 return s, nil 115} 116 117// normalize adjusts the foreground, windground, depth map, and background to 118// have matching widths and heights. It adjusts by cropping to the shortest 119// number of lines between the four and adjusts each line to the shortest of 120// any lines in any of the four maps. 121func (s *Scene) normalize() { 122 scenes := [...][][]rune{s.foreground, s.windground, s.depth, s.background} 123 124 s.Height = 999999999 125 for i := range scenes { 126 if len(scenes[i]) < s.Height { 127 s.Height = len(scenes[i]) 128 } 129 } 130 131 for i := range scenes { 132 scenes[i] = scenes[i][:s.Height] 133 } 134 135 s.Width = 999999999 136 for j := 0; j < s.Height; j++ { 137 for i := range scenes { 138 if len(scenes[i][j]) < s.Width { 139 s.Width = len(scenes[i][j]) 140 } 141 } 142 143 for i := range scenes { 144 scenes[i][j] = scenes[i][j][:s.Width] 145 } 146 } 147 148 // TODO: Change this to a map? Map can be iterated over and still referenced by name. 149 s.foreground = scenes[0] 150 s.windground = scenes[1] 151 s.depth = scenes[2] 152 s.background = scenes[3] 153} 154 155func (s Scene) GetCell(x, y, time int) (rune, Style) { 156 fx := float64(x) 157 fy := float64(y) 158 ftime := float64(time) 159 fcloud := float64(s.forecast.cloudiness) 160 fwind := float64(s.forecast.windiness) 161 frain := float64(s.forecast.raininess) 162 163 char := ' ' 164 style := Style{ 165 termenv.ANSI256Color(255), 166 termenv.ANSI256Color(232), 167 } 168 169 // Out of bounds 170 if y >= s.Height || y < 0 { 171 return char, Style{} 172 } 173 if x >= s.Width || x < 0 { 174 return char, Style{} 175 } 176 177 depth := uint8(s.depth[y][x] - 48) 178 179 // Char selection 180 char = s.foreground[y][x] 181 // Pull from wind map if the current cell is windswept. 182 if fwind > 0.0 && s.generator.Eval3(fx/40+fwind/8*ftime*2, fy/20, ftime/5+fwind/10*ftime/2) > 0.6 { 183 char = s.windground[y][x] 184 } 185 // Depth 9 is considered "transparent," so use the char from the background. 186 if depth == 9 { 187 char = s.background[y][x] 188 } 189 if frain > 0.0 && 0.1+frain/20 > s.generator.Eval3(fx+ftime*fwind*3, fy-ftime*5, ftime/15) { 190 char = '|' 191 if fwind > 1.0 { 192 char = '/' 193 } 194 } 195 196 // Style selection 197 // Calculate fog 198 fog := (depth - 1) + uint8(s.forecast.visibility-1)*2 199 if s.forecast.visibility == 0 { 200 fog = (depth + uint8(s.forecast.visibility)) / 2 201 } 202 // Don't show fog in the sky during the night. 203 if depth == 9 && (s.forecast.time == Night || s.forecast.time == EarlyMorning) { 204 fog = 0 205 } 206 207 // Add clouds 208 cloudiness := 0 209 if depth > 8 && fcloud/5 > s.generator.Eval3(fx/50+ftime/24+ftime*(fwind/8), fy/12, 1000+ftime/80) { 210 cloudiness += 10 211 } 212 if depth > 6 && fcloud/7 > s.generator.Eval3(fx/20+ftime/14+ftime*(fwind/8), fy/6, 0+ftime/80) { 213 cloudiness += 10 214 } 215 216 // At night, fog obscures distant objects with darkness. 217 if s.forecast.time == Night || s.forecast.time == EarlyMorning { 218 style.fg = termenv.ANSI256Color(255 - fog - fog/2) 219 } else { 220 // Merge fog with clouds during daytime. 221 cloudiness += int(fog) 222 } 223 224 if cloudiness > 23 { 225 cloudiness = 23 226 } 227 228 if cloudiness > 0 { 229 style.bg = termenv.ANSI256Color(232 + cloudiness) 230 } else { 231 style.bg = termenv.ANSI256Color(232 + fog) 232 } 233 return char, style 234} 235 236// readRunesFromFile reads the file at the given path and reads it character by 237// character, line by line into a slice of slices of runes. 238func readRunesFromFile(filepath string) ([][]rune, error) { 239 file, err := os.Open(filepath) 240 if err != nil { 241 return nil, err 242 } 243 244 br := bufio.NewReader(file) 245 img := make([][]rune, 0) 246 img = append(img, make([]rune, 0)) 247 248 for { 249 r, s, err := br.ReadRune() 250 if err != nil { 251 if err == io.EOF { 252 break 253 } 254 return nil, err 255 } 256 257 // Invalid unicode characters are skipped. 258 if r == unicode.ReplacementChar && s == 1 { 259 continue 260 } 261 262 // Start a new slice on newline, otherwise append character to current slice. 263 if r == '\n' { 264 img = append(img, make([]rune, 0)) 265 } else { 266 img[len(img)-1] = append(img[len(img)-1], r) 267 } 268 } 269 270 return img, nil 271}