Render dynamic weather for ASCII landscapes. Inspired and powered by ~iajrz's
climate program.
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}